diff --git a/integrationExamples/gpt/mock-vai.js b/integrationExamples/gpt/mock-vai.js
new file mode 100644
index 0000000000..32cb2d645a
--- /dev/null
+++ b/integrationExamples/gpt/mock-vai.js
@@ -0,0 +1,50 @@
+/**
+ * Mock VAI script for integration testing.
+ *
+ * Simulates what the real vai.js (from /pw/vai.js) would do:
+ * 1. Classify the actor (vat/act)
+ * 2. Build a signed assertion
+ * 3. Set window.__PW_VAI__ with the full payload
+ * 4. Call window.__PW_VAI_HOOK__ if registered
+ * 5. Cache in localStorage
+ *
+ * All JWT/JWS values are dummy strings — this is for structural
+ * verification only, not cryptographic validation.
+ */
+(function () {
+ 'use strict';
+
+ var payload = {
+ // Actor classification (user.ext.vai)
+ vat: 'HUMAN',
+ act: 'ACT-1',
+ // Domain provenance (site.ext.vai)
+ iss: 'paywalls.net',
+ dom: location.hostname,
+ // Signed assertion (user.ext.vai)
+ jws: 'eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3Qta2V5LTAwMSJ9.eyJpc3MiOiJwYXl3YWxscy5uZXQifQ.mock-signature',
+ // Meter/session token (user.ext.vai)
+ mstk: 'mock-mstk-token-001',
+ // Per-view token (imp[].ext.vai)
+ pvtk: 'mock-pvtk-token-001',
+ // Expiry — 1 hour from now
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ };
+
+ // Set window global
+ window.__PW_VAI__ = payload;
+
+ // Notify hook if registered
+ if (typeof window.__PW_VAI_HOOK__ === 'function') {
+ window.__PW_VAI_HOOK__(payload);
+ }
+
+ // Cache in localStorage
+ try {
+ localStorage.setItem('__pw_vai__', JSON.stringify(payload));
+ } catch (e) {
+ // Ignore — storage may be unavailable
+ }
+
+ console.log('[mock-vai.js] VAI payload set:', payload);
+})();
diff --git a/integrationExamples/gpt/paywallsAnalyticsAdapter_example.html b/integrationExamples/gpt/paywallsAnalyticsAdapter_example.html
new file mode 100644
index 0000000000..ff45e09bc2
--- /dev/null
+++ b/integrationExamples/gpt/paywallsAnalyticsAdapter_example.html
@@ -0,0 +1,177 @@
+
+
+
+ Paywalls Analytics Adapter — E2E Test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Paywalls Analytics Adapter — Integration Test
+ Verifies vai_vat and vai_act are emitted on each auction.
+
+ ⏳ Waiting for auction...
+ ⏳ vai_vat
+ ⏳ vai_act
+ ⏳ Only vat/act keys
+ ⏳ Running...
+
+ (ad slot)
+
+
+ ?degrade — test with VAI unavailable |
+ ?real — use real VAI from production
+
+
+
diff --git a/integrationExamples/gpt/paywallsRtdProvider_example.html b/integrationExamples/gpt/paywallsRtdProvider_example.html
new file mode 100644
index 0000000000..a82e0c66c7
--- /dev/null
+++ b/integrationExamples/gpt/paywallsRtdProvider_example.html
@@ -0,0 +1,257 @@
+
+
+
+ Paywalls RTD Provider — E2E Test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Paywalls RTD Provider — Integration Test
+
+ Test Results
+ ⏳ Waiting for Prebid to load...
+ ⏳ site.ext.data.vai
+ ⏳ user.ext.data.vai
+ ⏳ GAM targeting keys
+ ⏳ Timing
+ ⏳ Running...
+
+ Ad Slot (test-div)
+
+
+ (Ad slot — no real bids expected in test)
+
+
+
+ Debug
+ Open DevTools console for full ORTB2 and targeting output. Prebid debug: true is enabled.
+
+
diff --git a/modules/.submodules.json b/modules/.submodules.json
index 976af2945a..41524cff16 100644
--- a/modules/.submodules.json
+++ b/modules/.submodules.json
@@ -118,6 +118,7 @@
"overtoneRtdProvider",
"oxxionRtdProvider",
"panxoRtdProvider",
+ "paywallsRtdProvider",
"permutiveRtdProvider",
"pubmaticRtdProvider",
"pubxaiRtdProvider",
diff --git a/modules/paywallsAnalyticsAdapter.js b/modules/paywallsAnalyticsAdapter.js
new file mode 100644
index 0000000000..adb18b91b4
--- /dev/null
+++ b/modules/paywallsAnalyticsAdapter.js
@@ -0,0 +1,219 @@
+/**
+ * Paywalls VAI Analytics Adapter
+ *
+ * Emits VAI (Validated Actor Inventory) classification — vat and act —
+ * on each auction to the publisher's analytics pipeline (GA4, GTM
+ * dataLayer, or custom callback). Publishers use their existing
+ * reporting stack (GAM, GA4, warehouse) to slice any metric by traffic
+ * classification. No bid-level aggregation is done here; the RTD
+ * module already injects VAI into ORTB2 so SSPs and GAM can report
+ * on it natively.
+ *
+ * Publishers must load vai.js before Prebid.js initializes:
+ *
+ *
+ * @module modules/paywallsAnalyticsAdapter
+ * @see https://paywalls.net/docs/publishers/vai
+ */
+
+import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js';
+import adapterManager from '../src/adapterManager.js';
+import { EVENTS } from '../src/constants.js';
+import { logInfo, logWarn } from '../src/utils.js';
+
+const ADAPTER_CODE = 'paywalls';
+const LOG_PREFIX = '[PaywallsAnalytics] ';
+
+export const VAI_WINDOW_KEY = '__PW_VAI__';
+
+const { AUCTION_END } = EVENTS;
+
+// Track which auctions we've already emitted for (de-dup)
+let emittedAuctions = Object.create(null);
+
+// ---------------------------------------------------------------------------
+// Config (set during enableAnalytics)
+// ---------------------------------------------------------------------------
+
+export let adapterConfig = {
+ output: 'callback', // 'gtag' | 'dataLayer' | 'callback'
+ samplingRate: 1.0,
+ callback: null,
+};
+
+// Whether this page session is sampled in
+let sampledIn = true;
+
+// ---------------------------------------------------------------------------
+// VAI reading
+// ---------------------------------------------------------------------------
+
+/**
+ * Read VAI classification from the window global.
+ * @returns {{ vat: string, act: string } | null}
+ */
+export function getVaiClassification() {
+ const vai = window[VAI_WINDOW_KEY];
+ if (vai && typeof vai === 'object' && vai.vat && vai.act) {
+ if (typeof vai.exp === 'number' && vai.exp < Math.floor(Date.now() / 1000)) {
+ return null;
+ }
+ return { vat: vai.vat, act: vai.act };
+ }
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Metrics computation
+// ---------------------------------------------------------------------------
+
+/**
+ * Build the KVP object emitted per auction.
+ * Only contains VAI classification — vat and act.
+ * UNKNOWN values signal that VAI was not available (publisher did not
+ * load vai.js, or it returned an invalid/expired response).
+ * @returns {object} The KVP metrics object
+ */
+export function computeMetrics() {
+ const vai = getVaiClassification();
+ return {
+ vai_vat: vai ? vai.vat : 'UNKNOWN',
+ vai_act: vai ? vai.act : 'UNKNOWN',
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Output emission
+// ---------------------------------------------------------------------------
+
+/**
+ * Emit metrics via the configured output mode.
+ * @param {object} kvps The metrics key-value pairs
+ */
+export function emitMetrics(kvps) {
+ const output = adapterConfig.output;
+
+ logInfo(LOG_PREFIX + 'emitting metrics via ' + output + ':', kvps);
+
+ switch (output) {
+ case 'gtag':
+ if (typeof window.gtag === 'function') {
+ window.gtag('event', 'vai_auction', kvps);
+ } else {
+ logWarn(LOG_PREFIX + 'gtag not found on window — metrics not sent');
+ }
+ break;
+
+ case 'dataLayer':
+ window.dataLayer = window.dataLayer || [];
+ window.dataLayer.push(Object.assign({ event: 'vai_auction' }, kvps));
+ break;
+
+ case 'callback':
+ if (typeof adapterConfig.callback === 'function') {
+ try {
+ adapterConfig.callback(kvps);
+ } catch (e) {
+ logWarn(LOG_PREFIX + 'callback error:', e);
+ }
+ } else {
+ logWarn(LOG_PREFIX + 'output is "callback" but no callback function provided');
+ }
+ break;
+
+ default:
+ logWarn(LOG_PREFIX + 'unknown output mode: ' + output);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Event tracking
+// ---------------------------------------------------------------------------
+
+/**
+ * Handle a tracked Prebid event.
+ * We only care about AUCTION_END — emit classification once per auction.
+ * @param {{ eventType: string, args: object }} param
+ */
+function handleEvent({ eventType, args }) {
+ if (!sampledIn) return;
+ if (eventType !== AUCTION_END) return;
+
+ const auctionId = args && args.auctionId;
+ if (!auctionId) return;
+
+ // De-dup: only emit once per auction
+ if (emittedAuctions[auctionId]) return;
+ emittedAuctions[auctionId] = true;
+
+ const kvps = computeMetrics();
+ emitMetrics(kvps);
+}
+
+// ---------------------------------------------------------------------------
+// Adapter setup
+// ---------------------------------------------------------------------------
+
+const paywallsAnalytics = Object.assign(
+ adapter({ analyticsType: 'bundle' }),
+ {
+ track: handleEvent,
+ }
+);
+
+// Save original enableAnalytics
+paywallsAnalytics.originEnableAnalytics = paywallsAnalytics.enableAnalytics;
+
+/**
+ * Override enableAnalytics to parse config.
+ * @param {object} config
+ */
+paywallsAnalytics.enableAnalytics = function (config) {
+ const options = (config && config.options) || {};
+
+ // Parse config
+ adapterConfig.output = options.output || 'callback';
+ const rawRate = typeof options.samplingRate === 'number' ? options.samplingRate : 1.0;
+ adapterConfig.samplingRate = Math.max(0, Math.min(1, rawRate));
+ if (rawRate !== adapterConfig.samplingRate) {
+ logWarn(LOG_PREFIX + 'samplingRate clamped to [0, 1]: ' + rawRate + ' → ' + adapterConfig.samplingRate);
+ }
+ adapterConfig.callback = typeof options.callback === 'function' ? options.callback : null;
+
+ // Sampling decision
+ sampledIn = Math.random() < adapterConfig.samplingRate;
+ if (!sampledIn) {
+ logInfo(LOG_PREFIX + 'page excluded by sampling (rate=' + adapterConfig.samplingRate + ')');
+ }
+
+ // Reset state
+ emittedAuctions = Object.create(null);
+
+ // Call original enable (wires up event listeners)
+ paywallsAnalytics.originEnableAnalytics(config);
+};
+
+adapterManager.registerAnalyticsAdapter({
+ adapter: paywallsAnalytics,
+ code: ADAPTER_CODE,
+});
+
+/**
+ * Reset internal state — used by unit tests.
+ * @param {object} [overrides]
+ * @param {boolean} [overrides.sampledIn]
+ */
+export function resetForTesting(overrides) {
+ emittedAuctions = Object.create(null);
+ sampledIn = true;
+ adapterConfig.output = 'callback';
+ adapterConfig.samplingRate = 1.0;
+ adapterConfig.callback = null;
+ if (overrides) {
+ if (typeof overrides.sampledIn === 'boolean') {
+ sampledIn = overrides.sampledIn;
+ }
+ }
+}
+
+export default paywallsAnalytics;
diff --git a/modules/paywallsAnalyticsAdapter.md b/modules/paywallsAnalyticsAdapter.md
new file mode 100644
index 0000000000..c3294c5464
--- /dev/null
+++ b/modules/paywallsAnalyticsAdapter.md
@@ -0,0 +1,173 @@
+# Overview
+
+**Module Name:** Paywalls Analytics Adapter
+
+**Module Type:** Analytics Adapter
+
+**Maintainer:** [engineering@paywalls.net](mailto:engineering@paywalls.net)
+
+
+## Description
+
+The Paywalls Analytics Adapter emits [VAI (Validated Actor Inventory)](https://paywalls.net/docs/publishers/vai) classification on each Prebid auction. VAI helps publishers distinguish **human traffic** and **AI agents** from **non-human automation** (sharing/preview bots, search crawlers, AI training, etc.) so they can make better-informed economic decisions.
+
+In practice, this enables publishers to:
+
+- **Segment and analyze performance** by traffic class (yield, fill, viewability, buyer outcomes) in their existing analytics stack (GA4, GTM / dataLayer, or a custom callback)
+- **Route supply to appropriate demand** (e.g., prioritize premium demand paths for higher-confidence human traffic, apply different floors/controls to lower-confidence or non-human traffic)
+
+This can improve monetization efficiency and help buyers receive inventory that matches their quality requirements.
+
+Two key-value pairs are emitted per auction:
+
+| Key | Example Value | Description |
+|-----------|---------------|--------------------------------------------------------------|
+| `vai_vat` | `HUMAN` | Validated Actor Type — one of `HUMAN`, `AI_AGENT`, `SHARING`, `OTHER` |
+| `vai_act` | `ACT-1` | Actor Confidence Tier — one of `ACT-1`, `ACT-2`, `ACT-3` |
+
+If VAI is unavailable (script failed to load, timed out, or returned an invalid response), both values are `UNKNOWN`.
+
+> **No bid-level aggregation is performed.** The companion [Paywalls RTD Provider](paywallsRtdProvider.md) injects VAI into ORTB2 (`site.ext.vai`, `user.ext.vai`, `imp[].ext.vai`) and GAM targeting (`vai_vat`, `vai_act`), so SSPs, DSPs, GAM, and warehouse pipelines already have the signals needed to aggregate natively. The analytics adapter simply confirms classification is reaching the page and routes it to the publisher's analytics tool of choice.
+
+## Build Instructions
+
+```sh
+gulp build --modules=rtdModule,paywallsRtdProvider,paywallsAnalyticsAdapter
+```
+
+The RTD provider is not strictly required, but is recommended — it handles ORTB2 enrichment and GAM targeting. The analytics adapter independently reads the same `window.__PW_VAI__` global.
+
+## Configuration
+
+```javascript
+pbjs.enableAnalytics([{
+ provider: 'paywalls',
+ options: {
+ output: 'callback', // 'gtag' | 'dataLayer' | 'callback'
+ scriptUrl: '/pw/vai.js', // VAI loader URL
+ samplingRate: 1.0, // 0.0–1.0
+ callback: function (metrics) {
+ console.log(metrics);
+ // { vai_vat: 'HUMAN', vai_act: 'ACT-1' }
+ }
+ }
+}]);
+```
+
+### Parameters
+
+| Name | Type | Scope | Description | Default |
+|------------------------|------------|----------|--------------------------------------------------------|------------------|
+| `provider` | `String` | Required | Must be `'paywalls'` | — |
+| `options` | `Object` | Optional | Adapter configuration | `{}` |
+| `options.output` | `String` | Optional | Output mode: `'gtag'`, `'dataLayer'`, or `'callback'` | `'callback'` |
+| `options.scriptUrl` | `String` | Optional | URL of the VAI loader script | `'/pw/vai.js'` |
+| `options.samplingRate` | `Number` | Optional | Fraction of page views that emit analytics (0.0–1.0) | `1.0` |
+| `options.callback` | `Function` | Optional | Called with the metrics object when `output` is `'callback'` | `null` |
+
+### Hosting Modes
+
+VAI supports two hosting modes for the loader script:
+
+- **Publisher-hosted** (preferred): The script is served from the publisher's own domain via a CDN or server integration. Use the default relative path `'/pw/vai.js'`. This keeps requests same-origin, avoids CORS, and ensures the assertion's `dom` claim matches the inventory domain.
+- **Paywalls-hosted**: The script is served from `https://paywalls.net/pw/vai.js`. Set `scriptUrl` to the full URL. This mode requires paywalls.net configuration before usage. **Note** The domain provenance claim (`dom`) will reflect `paywalls.net` rather than the inventory domain, which may affect SSP verification and buyer trust.
+
+In both cases, `vai.js` makes a request to fetch `vai.json`, which contains the signed assertion. The publisher-hosted mode is same-origin; the Paywalls-hosted mode is cross-origin (CORS).
+
+## Output Modes
+
+### `gtag` — Google Analytics 4
+
+Fires a GA4 event via the global `gtag()` function:
+
+```javascript
+gtag('event', 'vai_auction', { vai_vat: 'HUMAN', vai_act: 'ACT-1' });
+```
+
+Requires the GA4 snippet (`gtag.js`) to be loaded on the page.
+
+### `dataLayer` — Google Tag Manager
+
+Pushes to the GTM `dataLayer` array:
+
+```javascript
+window.dataLayer.push({
+ event: 'vai_auction',
+ vai_vat: 'HUMAN',
+ vai_act: 'ACT-1'
+});
+```
+
+The array is created automatically if it does not exist. In GTM, create a Custom Event trigger on `vai_auction` to route the data to any tag.
+
+### `callback` — Custom Function
+
+Calls the provided function with the metrics object:
+
+```javascript
+callback({ vai_vat: 'HUMAN', vai_act: 'ACT-1' });
+```
+
+Use this for custom pipelines, data warehouses, or any destination not covered by the built-in modes.
+
+## Sampling
+
+Set `samplingRate` to a value between `0.0` and `1.0` to control cost. The decision is made once per page load — all auctions on that page either emit or don't.
+
+```javascript
+options: { samplingRate: 0.1 } // emit on ~10% of page views
+```
+
+## De-duplication
+
+The adapter emits **once per auction ID**. If Prebid fires multiple `AUCTION_END` events for the same auction (e.g. due to race conditions), only the first is processed.
+
+## Activity Controls
+
+The adapter uses `loadExternalScript` to inject `vai.js`. If your activity configuration restricts external scripts, allow the `paywalls` component:
+
+```javascript
+pbjs.setConfig({
+ allowActivities: {
+ loadExternalScript: {
+ default: false,
+ rules: [{
+ condition: function (params) {
+ return params.componentName === 'paywalls';
+ },
+ allow: true
+ }]
+ }
+ }
+});
+```
+
+## Privacy
+
+- **No user identifiers**: VAI does not collect, store, or transmit user IDs, cookies, or fingerprints.
+- **No PII**: The classification is based on aggregate session-level behavioral signals, not personal data.
+- **Browser-side only**: All signal extraction runs in the browser; no data leaves the page except the classification result.
+- **Signed assertions**: SSPs can independently verify the `jws` via the JWKS endpoint pulled from the JWS header (typically `https://example.com/pw/jwks.json`), ensuring the classification has not been tampered with.
+
+## Testing
+
+Run unit tests:
+
+```sh
+gulp test --nolint --file test/spec/modules/paywallsAnalyticsAdapter_spec.js
+```
+
+Run the integration test page:
+
+```sh
+gulp serve-fast --modules=rtdModule,paywallsRtdProvider,paywallsAnalyticsAdapter,appnexusBidAdapter
+# Open http://localhost:9999/integrationExamples/gpt/paywallsAnalyticsAdapter_example.html
+# Append ?real to test with real VAI (requires cloud-api on :8080)
+# Append ?degrade to test graceful degradation (VAI unavailable)
+```
+
+## Links
+
+- [VAI Documentation](https://paywalls.net/docs/publishers/vai)
+- [Paywalls RTD Provider](paywallsRtdProvider.md)
+- [Prebid Analytics Adapter Documentation](https://docs.prebid.org/dev-docs/integrate-with-the-prebid-analytics-api.html)
diff --git a/modules/paywallsRtdProvider.js b/modules/paywallsRtdProvider.js
new file mode 100644
index 0000000000..fec6e0757f
--- /dev/null
+++ b/modules/paywallsRtdProvider.js
@@ -0,0 +1,207 @@
+/**
+ * This module adds the Paywalls VAI (Validated Actor Inventory) provider
+ * to the real time data module.
+ *
+ * VAI classifies page impressions by actor type (vat) and confidence tier
+ * (act), producing a cryptographically signed assertion. The RTD submodule
+ * reads the VAI payload from window.__PW_VAI__ (populated by the
+ * publisher's vai.js script tag) and injects it into ORTB2.
+ *
+ * Publishers must load vai.js before Prebid.js initializes:
+ *
+ *
+ * ORTB2 placement (canonical split):
+ * site.ext.data.vai — { iss, dom }
+ * user.ext.data.vai — { iss, mstk, vat, act, jws }
+ * imp[].ext.vai — { pvtk }
+ *
+ * @module modules/paywallsRtdProvider
+ * @requires module:modules/realTimeData
+ * @see https://paywalls.net/docs/publishers/vai
+ */
+
+import { submodule } from '../src/hook.js';
+import { mergeDeep, logInfo, logWarn } from '../src/utils.js';
+
+/**
+ * @typedef {import('../modules/rtdModule/index.js').RtdSubmodule} RtdSubmodule
+ */
+
+const LOG_PREFIX = '[PaywallsRtd] ';
+const MODULE_NAME = 'realTimeData';
+
+export const SUBMODULE_NAME = 'paywalls';
+export const VAI_WINDOW_KEY = '__PW_VAI__';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Check whether a VAI payload is present and unexpired.
+ * @param {object|null} vai
+ * @returns {boolean}
+ */
+function isValid(vai) {
+ if (!vai || typeof vai !== 'object') return false;
+ if (!vai.vat || !vai.act) return false;
+ if (vai.exp && vai.exp < Math.floor(Date.now() / 1000)) return false;
+ return true;
+}
+
+/**
+ * Read the VAI payload from the window global.
+ * Returns the payload if valid and unexpired, null otherwise.
+ * @returns {object|null}
+ */
+export function getVaiPayload() {
+ const vai = window[VAI_WINDOW_KEY];
+ if (isValid(vai)) {
+ return vai;
+ }
+ return null;
+}
+
+/**
+ * Build the canonical ORTB2 split placement from a VAI payload.
+ *
+ * site.ext.data.vai — domain provenance (iss, dom)
+ * user.ext.data.vai — actor classification + signed assertion (iss, vat, act, mstk, jws)
+ *
+ * imp-level pvtk enrichment is handled separately in mergeOrtb2Fragments.
+ *
+ * @param {object} vai
+ * @returns {{site: object, user: object}}
+ */
+export function buildOrtb2(vai) {
+ return {
+ site: {
+ ext: {
+ data: {
+ vai: {
+ iss: vai.iss,
+ dom: vai.dom,
+ }
+ }
+ }
+ },
+ user: {
+ ext: {
+ data: {
+ vai: {
+ iss: vai.iss,
+ vat: vai.vat,
+ act: vai.act,
+ mstk: vai.mstk,
+ jws: vai.jws,
+ }
+ }
+ }
+ }
+ };
+}
+
+/**
+ * Merge VAI signals into the request-level ORTB2 fragments.
+ *
+ * - site.ext.data.vai and user.ext.data.vai are merged into ortb2Fragments.global
+ * - imp[].ext.vai.pvtk is merged into each ad unit's ortb2Imp (if pvtk is available)
+ *
+ * @param {object} reqBidsConfigObj
+ * @param {object} vai
+ */
+function mergeOrtb2Fragments(reqBidsConfigObj, vai) {
+ const ortb2 = buildOrtb2(vai);
+ const global = reqBidsConfigObj.ortb2Fragments.global;
+ mergeDeep(global, ortb2);
+
+ // Enrich imp-level ortb2 with pvtk when available
+ if (vai.pvtk && reqBidsConfigObj.adUnits) {
+ reqBidsConfigObj.adUnits.forEach(function (adUnit) {
+ adUnit.ortb2Imp = adUnit.ortb2Imp || {};
+ mergeDeep(adUnit.ortb2Imp, {
+ ext: { vai: { pvtk: vai.pvtk } }
+ });
+ });
+ }
+
+ logInfo(LOG_PREFIX + 'merged ORTB2 fragments. vat=' + vai.vat + ' act=' + vai.act + (vai.pvtk ? ' pvtk=' + vai.pvtk : ''));
+}
+
+// ---------------------------------------------------------------------------
+// RTD Submodule interface
+// ---------------------------------------------------------------------------
+
+/**
+ * init — called once when the submodule is registered.
+ *
+ * @param {object} rtdConfig Provider configuration from realTimeData.dataProviders
+ * @param {object} userConsent Consent data
+ * @returns {boolean} true to signal success; false would disable the module
+ */
+function init(rtdConfig, userConsent) {
+ return true;
+}
+
+/**
+ * getBidRequestData — called before each auction.
+ *
+ * Reads window.__PW_VAI__ — if valid and unexpired, merges ORTB2.
+ * If absent or expired, calls callback immediately (graceful degradation).
+ *
+ * Fully synchronous — the publisher is responsible for loading vai.js
+ * before Prebid runs.
+ *
+ * @param {object} reqBidsConfigObj The bid request config with ortb2Fragments
+ * @param {function} callback Must be called when done
+ * @param {object} rtdConfig Provider configuration
+ * @param {object} userConsent Consent data
+ */
+function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) {
+ const vai = getVaiPayload();
+ if (vai) {
+ mergeOrtb2Fragments(reqBidsConfigObj, vai);
+ } else {
+ logWarn(LOG_PREFIX + 'VAI unavailable — proceeding without enrichment');
+ }
+ callback();
+}
+
+/**
+ * getTargetingData — return GAM key-value pairs per ad unit.
+ *
+ * Returns { vai_vat, vai_act } for each ad unit code, enabling
+ * publishers to target GAM line items by actor classification.
+ *
+ * @param {string[]} adUnitArray Array of ad unit codes
+ * @param {object} rtdConfig Provider configuration
+ * @param {object} userConsent Consent data
+ * @returns {object} Object keyed by ad unit code
+ */
+function getTargetingData(adUnitArray, rtdConfig, userConsent) {
+ const vai = getVaiPayload();
+ if (!vai) return {};
+
+ const targeting = {};
+ adUnitArray.forEach(function (adUnitCode) {
+ targeting[adUnitCode] = {
+ vai_vat: vai.vat,
+ vai_act: vai.act,
+ };
+ });
+ return targeting;
+}
+
+// ---------------------------------------------------------------------------
+// Export & register
+// ---------------------------------------------------------------------------
+
+/** @type {RtdSubmodule} */
+export const paywallsSubmodule = {
+ name: SUBMODULE_NAME,
+ init: init,
+ getBidRequestData: getBidRequestData,
+ getTargetingData: getTargetingData,
+};
+
+submodule(MODULE_NAME, paywallsSubmodule);
diff --git a/modules/paywallsRtdProvider.md b/modules/paywallsRtdProvider.md
new file mode 100644
index 0000000000..327f1270f3
--- /dev/null
+++ b/modules/paywallsRtdProvider.md
@@ -0,0 +1,212 @@
+# Overview
+
+**Module Name:** Paywalls RTD Provider
+
+**Module Type:** RTD Provider
+
+**Maintainer:** [engineering@paywalls.net](mailto:engineering@paywalls.net)
+
+## Description
+
+The Paywalls RTD module integrates [VAI (Validated Actor Inventory)](https://paywalls.net/docs/publishers/vai) into Prebid.js. VAI classifies page impressions by **actor type** (`vat`) and **confidence tier** (`act`), producing a cryptographically signed assertion that SSPs can independently verify.
+
+The module automates VAI loading, timing, and signal injection:
+
+- **ORTB2 enrichment** — VAI signals are split across `site.ext.data.vai` (domain provenance), `user.ext.data.vai` (actor classification + signed assertion), and `imp[].ext.vai` (pageview correlation), available to all ORTB2-native bid adapters.
+- **GAM targeting** — `vai_vat` and `vai_act` key-value pairs are set per ad unit for Google Ad Manager line item targeting.
+- **Graceful degradation** — if VAI is unavailable or times out, the auction proceeds normally without enrichment.
+
+## Build Instructions
+
+Compile the Paywalls RTD module into your Prebid build:
+
+```sh
+gulp build --modules=rtdModule,paywallsRtdProvider
+```
+
+> The global RTD module (`rtdModule`) is a prerequisite.
+
+## Configuration
+
+```javascript
+pbjs.setConfig({
+ realTimeData: {
+ auctionDelay: 500,
+ dataProviders: [
+ {
+ name: 'paywalls',
+ waitForIt: true,
+ params: {
+ scriptUrl: '/pw/vai.js'
+ }
+ }
+ ]
+ }
+});
+```
+
+### Parameters
+
+| Name | Type | Scope | Description | Default |
+|---------------------|-----------|----------|--------------------------------------------------------|------------------|
+| `name` | `String` | Required | Must be `'paywalls'` | — |
+| `waitForIt` | `Boolean` | Optional | Should be `true` when `auctionDelay` is set | `false` |
+| `params` | `Object` | Optional | Provider configuration | `{}` |
+| `params.scriptUrl` | `String` | Optional | URL of the VAI loader script | `'/pw/vai.js'` |
+| `params.timeout` | `Number` | Optional | Max ms to wait for VAI before releasing the auction | `100` |
+
+### Hosting Modes
+
+VAI supports two hosting modes for the loader script:
+
+- **Publisher-hosted** (preferred): The script is served from the publisher's own domain via a CDN or server integration. Use the default relative path `'/pw/vai.js'`. This keeps requests same-origin, avoids CORS, and ensures the assertion's `dom` claim matches the inventory domain.
+- **Paywalls-hosted**: The script is served from `https://paywalls.net/pw/vai.js`. Set `scriptUrl` to the full URL. This mode requires paywalls.net configuration before usage. **Note** The domain provenance claim (`dom`) will reflect `paywalls.net` rather than the inventory domain, which may affect SSP verification and buyer trust.
+
+In both cases, `vai.js` makes a request to fetch `vai.json`, which contains the signed assertion. The publisher-hosted mode is same-origin; the Paywalls-hosted mode is cross-origin (CORS).
+
+## ORTB2 Output
+
+VAI signals are placed in the global ORTB2 within the `site`, `user`, and `imp` sections:
+
+### `site.ext.data.vai` — Domain Provenance
+
+Fields that describe the assertion context (who issued it, for which domain). The `dom` value can be cryptographically verified through the signed `jws` in `user.ext.data.vai`.
+
+```json
+{
+ "site": {
+ "ext": {
+ "data": {
+ "vai": {
+ "iss": "paywalls.net",
+ "dom": "example.com"
+ }
+ }
+ }
+ }
+}
+```
+
+| Field | Description |
+|-------|--------------------------------------------------|
+| `iss` | Issuer — bare domain (e.g. `paywalls.net`) |
+| `dom` | Domain the assertion covers |
+
+### `user.ext.data.vai` — Actor Classification
+
+Fields that describe the classified actor and the signed assertion:
+
+```json
+{
+ "user": {
+ "ext": {
+ "data": {
+ "vai": {
+ "iss": "paywalls.net",
+ "mstk": "01J4X9K2ABCDEF01234567",
+ "vat": "HUMAN",
+ "act": "ACT-1",
+ "jws": "eyJhbGciOiJFZERTQSIs..."
+ }
+ }
+ }
+ }
+}
+```
+
+| Field | Description |
+|--------|--------------------------------------------------------------|
+| `iss` | Issuer — bare domain (e.g. `paywalls.net`) |
+| `mstk` | Micro-session token — unique per assertion |
+| `vat` | Validated Actor Type — one of `HUMAN`, `AI_AGENT`, `SHARING`, `OTHER` |
+| `act` | Actor Confidence Tier — one of `ACT-1`, `ACT-2`, `ACT-3` |
+| `jws` | Full JWS (compact serialization) for SSP and DSP verification |
+
+### `imp[].ext.vai` — Pageview Correlation
+
+Set on each ad unit's `ortb2Imp` when `pvtk` is available from the VAI payload:
+
+```json
+{
+ "imp": [{
+ "ext": {
+ "vai": {
+ "pvtk": "01J4X9K2ABCDEF01234567/3"
+ }
+ }
+ }]
+}
+```
+
+| Field | Description |
+|--------|--------------------------------------------------------------|
+| `pvtk` | Pageview token — client-derived, unsigned; correlates impressions within a pageview using the `mstk` root |
+
+## GAM Targeting
+
+The module sets key-value pairs on every ad unit for Google Ad Manager targeting:
+
+| Key | Example Value | Description |
+|-----------|---------------|---------------------|
+| `vai_vat` | `HUMAN` | Actor type |
+| `vai_act` | `ACT-1` | Confidence tier |
+
+These are available via `pbjs.getAdserverTargeting()` and are compatible with standard GPT integration.
+
+## Activity Controls
+
+Publishers can restrict which activities the Paywalls module is allowed to perform. The module uses `loadExternalScript` to inject `vai.js`, which is gated by the `loadExternalScript` activity. If your activity configuration denies this by default, explicitly allow it for the `paywalls` component:
+
+```javascript
+pbjs.setConfig({
+ allowActivities: {
+ loadExternalScript: {
+ default: false,
+ rules: [
+ {
+ condition: function (params) {
+ return params.componentName === 'paywalls';
+ },
+ allow: true
+ }
+ ]
+ }
+ }
+});
+```
+
+## Privacy
+
+- **No user identifiers**: VAI does not collect, store, or transmit user IDs, cookies, or fingerprints.
+- **No PII**: The classification is based on aggregate session-level behavioral signals, not personal data.
+- **Browser-side only**: All signal extraction runs in the browser; no data leaves the page except the classification result.
+- **Signed assertions**: SSPs can independently verify the `jws` via the JWKS endpoint pulled from the JWS header (typically `https://example.com/pw/jwks.json`), ensuring the classification has not been tampered with.
+
+## How It Works
+
+1. **`init()`** — Checks `window.__PW_VAI__` and `localStorage` (key: `__pw_vai__`) for an existing VAI payload. If present and unexpired (`exp` > now), caches it for immediate use.
+2. **`getBidRequestData()`** — If cached VAI exists, merges ORTB2 and calls back immediately (fast path). Otherwise, injects `vai.js` via `loadExternalScript`, sets up `window.__PW_VAI_HOOK__` as a callback for the script to deliver the payload, and polls `window.__PW_VAI__` until `waitForIt` ms elapse (slow path). On timeout, calls back without enrichment.
+3. **`getTargetingData()`** — Returns `{ vai_vat, vai_act }` for each ad unit from the current VAI payload.
+
+## Testing
+
+Run unit tests:
+
+```sh
+gulp test --nolint --file test/spec/modules/paywallsRtdProvider_spec.js
+```
+
+Run the integration test page:
+
+```sh
+gulp serve-fast --modules=rtdModule,paywallsRtdProvider,appnexusBidAdapter
+# Open http://localhost:9999/integrationExamples/gpt/paywallsRtdProvider_example.html
+# Append ?real to test with real VAI (requires cloud-api on :8080)
+# Append ?degrade to test graceful degradation
+```
+
+## Links
+
+- [VAI Documentation](https://paywalls.net/docs/publishers/vai)
+- [Prebid RTD Module Documentation](https://docs.prebid.org/dev-docs/add-rtd-submodule.html)
+- [How Bid Adapters Should Read First Party Data](https://docs.prebid.org/features/firstPartyData.html#how-bid-adapters-should-read-first-party-data)
diff --git a/test/spec/modules/paywallsAnalyticsAdapter_spec.js b/test/spec/modules/paywallsAnalyticsAdapter_spec.js
new file mode 100644
index 0000000000..c5b7a1bd78
--- /dev/null
+++ b/test/spec/modules/paywallsAnalyticsAdapter_spec.js
@@ -0,0 +1,435 @@
+import paywallsAnalytics, {
+ getVaiClassification,
+ computeMetrics,
+ emitMetrics,
+ adapterConfig,
+ resetForTesting,
+ VAI_WINDOW_KEY,
+} from 'modules/paywallsAnalyticsAdapter.js';
+import { expect } from 'chai';
+import { expectEvents } from '../../helpers/analytics.js';
+import { EVENTS } from 'src/constants.js';
+import sinon from 'sinon';
+
+const adapterManager = require('src/adapterManager').default;
+const events = require('src/events');
+
+describe('PaywallsAnalyticsAdapter', function () {
+ let sandbox;
+ let clock;
+
+ before(function () {
+ sandbox = sinon.createSandbox();
+ clock = sandbox.useFakeTimers(1896134400000);
+ });
+
+ after(function () {
+ clock.restore();
+ sandbox.restore();
+ });
+
+ // -----------------------------------------------------------------------
+ // Registration & event tracking
+ // -----------------------------------------------------------------------
+
+ describe('registration', function () {
+ beforeEach(function () {
+ sandbox.stub(events, 'getEvents').returns([]);
+ resetForTesting();
+ adapterManager.enableAnalytics({
+ provider: 'paywalls',
+ options: { output: 'callback', callback: function () {} }
+ });
+ });
+
+ afterEach(function () {
+ events.getEvents.restore();
+ paywallsAnalytics.disableAnalytics();
+ resetForTesting();
+ delete window[VAI_WINDOW_KEY];
+ });
+
+ it('should register with the analytics adapter manager', function () {
+ expect(paywallsAnalytics.enableAnalytics).to.be.a('function');
+ });
+
+ it('should catch all events', function () {
+ sandbox.spy(paywallsAnalytics, 'track');
+ expectEvents().to.beTrackedBy(paywallsAnalytics.track);
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // VAI classification reading
+ // -----------------------------------------------------------------------
+
+ describe('getVaiClassification', function () {
+ afterEach(function () {
+ delete window[VAI_WINDOW_KEY];
+ resetForTesting();
+ });
+
+ it('should detect VAI when window.__PW_VAI__ is present', function () {
+ window[VAI_WINDOW_KEY] = { vat: 'HUMAN', act: 'ACT-1' };
+ const result = getVaiClassification();
+ expect(result).to.deep.equal({ vat: 'HUMAN', act: 'ACT-1' });
+ });
+
+ it('should return null when window.__PW_VAI__ is absent', function () {
+ delete window[VAI_WINDOW_KEY];
+ const result = getVaiClassification();
+ expect(result).to.be.null;
+ });
+
+ it('should return null for invalid VAI (missing vat)', function () {
+ window[VAI_WINDOW_KEY] = { act: 'ACT-1' };
+ expect(getVaiClassification()).to.be.null;
+ });
+
+ it('should return null for invalid VAI (missing act)', function () {
+ window[VAI_WINDOW_KEY] = { vat: 'HUMAN' };
+ expect(getVaiClassification()).to.be.null;
+ });
+
+ it('should return null for expired VAI', function () {
+ const nowSec = Math.floor(Date.now() / 1000);
+ window[VAI_WINDOW_KEY] = { vat: 'HUMAN', act: 'ACT-1', exp: nowSec - 1 };
+ expect(getVaiClassification()).to.be.null;
+ });
+
+ it('should return null for truthy but invalid __PW_VAI__', function () {
+ window[VAI_WINDOW_KEY] = { invalid: true };
+ expect(getVaiClassification()).to.be.null;
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Metrics computation
+ // -----------------------------------------------------------------------
+
+ describe('computeMetrics', function () {
+ afterEach(function () {
+ delete window[VAI_WINDOW_KEY];
+ });
+
+ it('should return vat and act when VAI is present', function () {
+ window[VAI_WINDOW_KEY] = { vat: 'HUMAN', act: 'ACT-1' };
+ const result = computeMetrics();
+ expect(result).to.deep.equal({ vai_vat: 'HUMAN', vai_act: 'ACT-1' });
+ });
+
+ it('should return UNKNOWN when VAI is absent', function () {
+ delete window[VAI_WINDOW_KEY];
+ const result = computeMetrics();
+ expect(result).to.deep.equal({ vai_vat: 'UNKNOWN', vai_act: 'UNKNOWN' });
+ });
+
+ it('should return UNKNOWN when VAI is expired', function () {
+ const nowSec = Math.floor(Date.now() / 1000);
+ window[VAI_WINDOW_KEY] = { vat: 'HUMAN', act: 'ACT-1', exp: nowSec - 1 };
+ const result = computeMetrics();
+ expect(result).to.deep.equal({ vai_vat: 'UNKNOWN', vai_act: 'UNKNOWN' });
+ });
+
+ it('should only contain vai_vat and vai_act keys', function () {
+ window[VAI_WINDOW_KEY] = { vat: 'AI_AGENT', act: 'ACT-2' };
+ const result = computeMetrics();
+ expect(Object.keys(result)).to.have.lengthOf(2);
+ expect(result).to.have.all.keys('vai_vat', 'vai_act');
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Output modes
+ // -----------------------------------------------------------------------
+
+ describe('output modes', function () {
+ afterEach(function () {
+ delete window[VAI_WINDOW_KEY];
+ resetForTesting();
+ });
+
+ describe('gtag', function () {
+ it('should call gtag with event and KVPs', function () {
+ const gtagStub = sandbox.stub();
+ window.gtag = gtagStub;
+
+ const kvps = { vai_vat: 'HUMAN', vai_act: 'ACT-1' };
+ adapterConfig.output = 'gtag';
+ emitMetrics(kvps);
+
+ expect(gtagStub.calledOnce).to.be.true;
+ expect(gtagStub.firstCall.args[0]).to.equal('event');
+ expect(gtagStub.firstCall.args[1]).to.equal('vai_auction');
+ expect(gtagStub.firstCall.args[2]).to.deep.include({ vai_vat: 'HUMAN' });
+
+ delete window.gtag;
+ });
+
+ it('should warn when gtag is not present', function () {
+ delete window.gtag;
+ adapterConfig.output = 'gtag';
+ emitMetrics({ vai_vat: 'HUMAN', vai_act: 'ACT-1' });
+ });
+ });
+
+ describe('dataLayer', function () {
+ it('should push to dataLayer with event name and KVPs', function () {
+ window.dataLayer = [];
+ adapterConfig.output = 'dataLayer';
+
+ const kvps = { vai_vat: 'HUMAN', vai_act: 'ACT-1' };
+ emitMetrics(kvps);
+
+ expect(window.dataLayer.length).to.equal(1);
+ expect(window.dataLayer[0].event).to.equal('vai_auction');
+ expect(window.dataLayer[0].vai_vat).to.equal('HUMAN');
+ expect(window.dataLayer[0].vai_act).to.equal('ACT-1');
+
+ delete window.dataLayer;
+ });
+
+ it('should create dataLayer if not present', function () {
+ delete window.dataLayer;
+ adapterConfig.output = 'dataLayer';
+
+ emitMetrics({ vai_vat: 'HUMAN', vai_act: 'ACT-1' });
+
+ expect(window.dataLayer).to.be.an('array');
+ expect(window.dataLayer.length).to.equal(1);
+
+ delete window.dataLayer;
+ });
+ });
+
+ describe('callback', function () {
+ it('should call the provided callback with KVPs', function () {
+ const cb = sandbox.stub();
+ adapterConfig.output = 'callback';
+ adapterConfig.callback = cb;
+
+ const kvps = { vai_vat: 'HUMAN', vai_act: 'ACT-1' };
+ emitMetrics(kvps);
+
+ expect(cb.calledOnce).to.be.true;
+ expect(cb.firstCall.args[0]).to.deep.equal(kvps);
+ });
+
+ it('should handle callback errors gracefully', function () {
+ adapterConfig.output = 'callback';
+ adapterConfig.callback = function () { throw new Error('test error'); };
+ emitMetrics({ vai_vat: 'HUMAN', vai_act: 'ACT-1' });
+ });
+
+ it('should warn when callback is not a function', function () {
+ adapterConfig.output = 'callback';
+ adapterConfig.callback = null;
+ emitMetrics({ vai_vat: 'HUMAN', vai_act: 'ACT-1' });
+ });
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // End-to-end event flow
+ // -----------------------------------------------------------------------
+
+ describe('event correlation', function () {
+ let callbackSpy;
+
+ beforeEach(function () {
+ callbackSpy = sandbox.stub();
+ sandbox.stub(events, 'getEvents').returns([]);
+ resetForTesting();
+ window[VAI_WINDOW_KEY] = { vat: 'HUMAN', act: 'ACT-1' };
+
+ adapterManager.enableAnalytics({
+ provider: 'paywalls',
+ options: {
+ output: 'callback',
+ callback: callbackSpy,
+ }
+ });
+ });
+
+ afterEach(function () {
+ events.getEvents.restore();
+ paywallsAnalytics.disableAnalytics();
+ resetForTesting();
+ delete window[VAI_WINDOW_KEY];
+ });
+
+ it('should emit vat and act on auctionEnd', function () {
+ events.emit(EVENTS.AUCTION_END, { auctionId: 'test-001' });
+ clock.tick(200);
+
+ expect(callbackSpy.calledOnce).to.be.true;
+ const kvps = callbackSpy.firstCall.args[0];
+ expect(kvps.vai_vat).to.equal('HUMAN');
+ expect(kvps.vai_act).to.equal('ACT-1');
+ expect(Object.keys(kvps)).to.have.lengthOf(2);
+ });
+
+ it('should emit once per auction (de-dup)', function () {
+ events.emit(EVENTS.AUCTION_END, { auctionId: 'test-dedup' });
+ clock.tick(200);
+ events.emit(EVENTS.AUCTION_END, { auctionId: 'test-dedup' });
+ clock.tick(200);
+
+ expect(callbackSpy.calledOnce).to.be.true;
+ });
+
+ it('should emit separately for different auctions', function () {
+ events.emit(EVENTS.AUCTION_END, { auctionId: 'auction-A' });
+ clock.tick(200);
+ events.emit(EVENTS.AUCTION_END, { auctionId: 'auction-B' });
+ clock.tick(200);
+
+ expect(callbackSpy.calledTwice).to.be.true;
+ expect(callbackSpy.firstCall.args[0].vai_vat).to.equal('HUMAN');
+ expect(callbackSpy.secondCall.args[0].vai_vat).to.equal('HUMAN');
+ });
+
+ it('should ignore events without auctionId', function () {
+ events.emit(EVENTS.AUCTION_END, {});
+ clock.tick(200);
+
+ expect(callbackSpy.called).to.be.false;
+ });
+
+ it('should ignore non-AUCTION_END events', function () {
+ events.emit(EVENTS.BID_RESPONSE, { auctionId: 'test-001', cpm: 2.50 });
+ events.emit(EVENTS.BID_WON, { auctionId: 'test-001' });
+ events.emit(EVENTS.NO_BID, { auctionId: 'test-001' });
+ clock.tick(200);
+
+ expect(callbackSpy.called).to.be.false;
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Sampling
+ // -----------------------------------------------------------------------
+
+ describe('sampling', function () {
+ afterEach(function () {
+ delete window[VAI_WINDOW_KEY];
+ resetForTesting();
+ });
+
+ it('should skip emission when sampled out', function () {
+ const cb = sandbox.stub();
+ sandbox.stub(events, 'getEvents').returns([]);
+ window[VAI_WINDOW_KEY] = { vat: 'HUMAN', act: 'ACT-1' };
+
+ sandbox.stub(Math, 'random').returns(0.5);
+ adapterManager.enableAnalytics({
+ provider: 'paywalls',
+ options: {
+ output: 'callback',
+ callback: cb,
+ samplingRate: 0.1
+ }
+ });
+
+ events.emit(EVENTS.AUCTION_END, { auctionId: 'sampled-out' });
+ clock.tick(200);
+
+ expect(cb.called).to.be.false;
+
+ Math.random.restore();
+ events.getEvents.restore();
+ paywallsAnalytics.disableAnalytics();
+ });
+
+ it('should emit when sampled in', function () {
+ const cb = sandbox.stub();
+ sandbox.stub(events, 'getEvents').returns([]);
+ window[VAI_WINDOW_KEY] = { vat: 'HUMAN', act: 'ACT-1' };
+
+ sandbox.stub(Math, 'random').returns(0.01);
+ adapterManager.enableAnalytics({
+ provider: 'paywalls',
+ options: {
+ output: 'callback',
+ callback: cb,
+ samplingRate: 0.5
+ }
+ });
+
+ events.emit(EVENTS.AUCTION_END, { auctionId: 'sampled-in' });
+ clock.tick(200);
+
+ expect(cb.calledOnce).to.be.true;
+
+ Math.random.restore();
+ events.getEvents.restore();
+ paywallsAnalytics.disableAnalytics();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Graceful degradation
+ // -----------------------------------------------------------------------
+
+ describe('graceful degradation', function () {
+ it('should emit UNKNOWN when VAI is unavailable', function () {
+ const cb = sandbox.stub();
+ sandbox.stub(events, 'getEvents').returns([]);
+ resetForTesting();
+ delete window[VAI_WINDOW_KEY];
+
+ adapterManager.enableAnalytics({
+ provider: 'paywalls',
+ options: {
+ output: 'callback',
+ callback: cb,
+ }
+ });
+
+ events.emit(EVENTS.AUCTION_END, { auctionId: 'degraded' });
+ clock.tick(200);
+
+ expect(cb.calledOnce).to.be.true;
+ const kvps = cb.firstCall.args[0];
+ expect(kvps.vai_vat).to.equal('UNKNOWN');
+ expect(kvps.vai_act).to.equal('UNKNOWN');
+
+ events.getEvents.restore();
+ paywallsAnalytics.disableAnalytics();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Config parsing
+ // -----------------------------------------------------------------------
+
+ describe('configuration', function () {
+ beforeEach(function () {
+ sandbox.stub(events, 'getEvents').returns([]);
+ resetForTesting();
+ });
+
+ afterEach(function () {
+ events.getEvents.restore();
+ paywallsAnalytics.disableAnalytics();
+ resetForTesting();
+ delete window[VAI_WINDOW_KEY];
+ });
+
+ it('should default samplingRate to 1.0', function () {
+ adapterManager.enableAnalytics({
+ provider: 'paywalls',
+ options: { output: 'callback', callback: function () {} }
+ });
+ expect(adapterConfig.samplingRate).to.equal(1.0);
+ });
+
+ it('should default output to callback', function () {
+ adapterManager.enableAnalytics({
+ provider: 'paywalls',
+ options: { callback: function () {} }
+ });
+ expect(adapterConfig.output).to.equal('callback');
+ });
+ });
+});
diff --git a/test/spec/modules/paywallsRtdProvider_spec.js b/test/spec/modules/paywallsRtdProvider_spec.js
new file mode 100644
index 0000000000..433b75a0f0
--- /dev/null
+++ b/test/spec/modules/paywallsRtdProvider_spec.js
@@ -0,0 +1,279 @@
+import { expect } from 'chai';
+import sinon from 'sinon';
+import {
+ paywallsSubmodule,
+ getVaiPayload,
+ buildOrtb2,
+ SUBMODULE_NAME,
+ VAI_WINDOW_KEY,
+} from 'modules/paywallsRtdProvider.js';
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const MOCK_VAI = {
+ iss: 'paywalls.net',
+ dom: 'example.com',
+ kid: '2026-01-a',
+ vat: 'HUMAN',
+ act: 'ACT-1',
+ mstk: '01J4X9K2ABCDEF01234567',
+ jws: 'eyJhbGciOiJFZERTQSJ9.eyJ2YXQiOiJIVU1BTiJ9.signature',
+ pvtk: '01J4X9K2ABCDEF01234567/1',
+ iat: Math.floor(Date.now() / 1000) - 10,
+ exp: Math.floor(Date.now() / 1000) + 60,
+};
+
+const MOCK_VAI_EXPIRED = {
+ ...MOCK_VAI,
+ exp: Math.floor(Date.now() / 1000) - 10,
+};
+
+const MOCK_CONFIG = {
+ name: SUBMODULE_NAME,
+ params: {},
+};
+
+function makeReqBids(existingOrtb2 = {}, adUnits = [{ code: 'ad-unit-1' }, { code: 'ad-unit-2' }]) {
+ return {
+ ortb2Fragments: {
+ global: existingOrtb2,
+ },
+ adUnits: adUnits,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('paywallsRtdProvider', function () {
+ beforeEach(function () {
+ delete window[VAI_WINDOW_KEY];
+ });
+
+ afterEach(function () {
+ delete window[VAI_WINDOW_KEY];
+ });
+
+ // -------------------------------------------------------------------------
+ // Submodule registration
+ // -------------------------------------------------------------------------
+
+ describe('submodule registration', function () {
+ it('has the correct name', function () {
+ expect(paywallsSubmodule.name).to.equal('paywalls');
+ });
+
+ it('exposes init, getBidRequestData, and getTargetingData', function () {
+ expect(paywallsSubmodule.init).to.be.a('function');
+ expect(paywallsSubmodule.getBidRequestData).to.be.a('function');
+ expect(paywallsSubmodule.getTargetingData).to.be.a('function');
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // init
+ // -------------------------------------------------------------------------
+
+ describe('init', function () {
+ it('returns true to signal successful registration', function () {
+ expect(paywallsSubmodule.init(MOCK_CONFIG, {})).to.equal(true);
+ });
+
+ it('returns true even when VAI is absent', function () {
+ expect(paywallsSubmodule.init(MOCK_CONFIG, {})).to.equal(true);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // buildOrtb2
+ // -------------------------------------------------------------------------
+
+ describe('buildOrtb2', function () {
+ it('places site fields at site.ext.data.vai', function () {
+ const ortb2 = buildOrtb2(MOCK_VAI);
+ expect(ortb2.site.ext.data.vai).to.deep.equal({
+ iss: MOCK_VAI.iss,
+ dom: MOCK_VAI.dom,
+ });
+ });
+
+ it('places user fields at user.ext.data.vai', function () {
+ const ortb2 = buildOrtb2(MOCK_VAI);
+ expect(ortb2.user.ext.data.vai).to.deep.equal({
+ iss: MOCK_VAI.iss,
+ vat: MOCK_VAI.vat,
+ act: MOCK_VAI.act,
+ mstk: MOCK_VAI.mstk,
+ jws: MOCK_VAI.jws,
+ });
+ });
+
+ it('does not place fields at imp level (imp handled in mergeOrtb2Fragments)', function () {
+ const ortb2 = buildOrtb2(MOCK_VAI);
+ expect(ortb2).to.not.have.property('imp');
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // getVaiPayload
+ // -------------------------------------------------------------------------
+
+ describe('getVaiPayload', function () {
+ it('returns window.__PW_VAI__ when present and unexpired', function () {
+ window[VAI_WINDOW_KEY] = { ...MOCK_VAI };
+ const payload = getVaiPayload();
+ expect(payload).to.not.be.null;
+ expect(payload.vat).to.equal('HUMAN');
+ });
+
+ it('returns null when window.__PW_VAI__ is expired', function () {
+ window[VAI_WINDOW_KEY] = { ...MOCK_VAI_EXPIRED };
+ const payload = getVaiPayload();
+ expect(payload).to.be.null;
+ });
+
+ it('returns null when nothing is available', function () {
+ const payload = getVaiPayload();
+ expect(payload).to.be.null;
+ });
+
+ it('returns null for invalid payload (missing vat)', function () {
+ window[VAI_WINDOW_KEY] = { act: 'ACT-1', iss: 'paywalls.net' };
+ expect(getVaiPayload()).to.be.null;
+ });
+
+ it('returns null for invalid payload (missing act)', function () {
+ window[VAI_WINDOW_KEY] = { vat: 'HUMAN', iss: 'paywalls.net' };
+ expect(getVaiPayload()).to.be.null;
+ });
+
+ it('returns null for non-object values', function () {
+ window[VAI_WINDOW_KEY] = 'not-an-object';
+ expect(getVaiPayload()).to.be.null;
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // getBidRequestData
+ // -------------------------------------------------------------------------
+
+ describe('getBidRequestData', function () {
+ it('merges ORTB2 when VAI is present on window', function (done) {
+ window[VAI_WINDOW_KEY] = { ...MOCK_VAI };
+ const reqBids = makeReqBids();
+
+ paywallsSubmodule.getBidRequestData(reqBids, function () {
+ const global = reqBids.ortb2Fragments.global;
+ expect(global.site.ext.data.vai.dom).to.equal('example.com');
+ expect(global.site.ext.data.vai.iss).to.equal('paywalls.net');
+ expect(global.user.ext.data.vai.jws).to.equal(MOCK_VAI.jws);
+ expect(global.user.ext.data.vai.mstk).to.equal(MOCK_VAI.mstk);
+ expect(global.user.ext.data.vai.vat).to.equal('HUMAN');
+ expect(global.user.ext.data.vai.act).to.equal('ACT-1');
+ // pvtk should be set at imp level on each ad unit
+ reqBids.adUnits.forEach(function (adUnit) {
+ expect(adUnit.ortb2Imp.ext.vai.pvtk).to.equal(MOCK_VAI.pvtk);
+ });
+ done();
+ }, MOCK_CONFIG, {});
+ });
+
+ it('calls callback without enrichment when VAI is absent', function (done) {
+ const reqBids = makeReqBids();
+
+ paywallsSubmodule.getBidRequestData(reqBids, function () {
+ const global = reqBids.ortb2Fragments.global;
+ expect(global).to.not.have.nested.property('site.ext.data.vai');
+ done();
+ }, MOCK_CONFIG, {});
+ });
+
+ it('calls callback without enrichment when VAI is expired', function (done) {
+ window[VAI_WINDOW_KEY] = { ...MOCK_VAI_EXPIRED };
+ const reqBids = makeReqBids();
+
+ paywallsSubmodule.getBidRequestData(reqBids, function () {
+ const global = reqBids.ortb2Fragments.global;
+ expect(global).to.not.have.nested.property('site.ext.data.vai');
+ done();
+ }, MOCK_CONFIG, {});
+ });
+
+ it('does not overwrite existing ortb2 fragments', function (done) {
+ window[VAI_WINDOW_KEY] = { ...MOCK_VAI };
+ const reqBids = makeReqBids({
+ site: { name: 'MySite' },
+ });
+
+ paywallsSubmodule.getBidRequestData(reqBids, function () {
+ const global = reqBids.ortb2Fragments.global;
+ expect(global.site.name).to.equal('MySite');
+ expect(global.site.ext.data.vai.dom).to.equal('example.com');
+ done();
+ }, MOCK_CONFIG, {});
+ });
+
+ it('injects pvtk at imp level on each ad unit', function (done) {
+ window[VAI_WINDOW_KEY] = { ...MOCK_VAI };
+ const reqBids = makeReqBids({}, [{ code: 'slot-a' }, { code: 'slot-b' }, { code: 'slot-c' }]);
+
+ paywallsSubmodule.getBidRequestData(reqBids, function () {
+ reqBids.adUnits.forEach(function (adUnit) {
+ expect(adUnit.ortb2Imp).to.have.nested.property('ext.vai.pvtk');
+ expect(adUnit.ortb2Imp.ext.vai.pvtk).to.equal(MOCK_VAI.pvtk);
+ });
+ done();
+ }, MOCK_CONFIG, {});
+ });
+
+ it('skips imp-level pvtk when pvtk is absent from VAI payload', function (done) {
+ const vaiNoPvtk = { ...MOCK_VAI };
+ delete vaiNoPvtk.pvtk;
+ window[VAI_WINDOW_KEY] = vaiNoPvtk;
+ const reqBids = makeReqBids();
+
+ paywallsSubmodule.getBidRequestData(reqBids, function () {
+ const global = reqBids.ortb2Fragments.global;
+ expect(global.user.ext.data.vai.vat).to.equal('HUMAN');
+ reqBids.adUnits.forEach(function (adUnit) {
+ expect(adUnit).to.not.have.nested.property('ortb2Imp.ext.vai');
+ });
+ done();
+ }, MOCK_CONFIG, {});
+ });
+
+ it('always calls callback (never blocks the auction)', function (done) {
+ // No VAI, no script — callback must still fire
+ const reqBids = makeReqBids();
+ paywallsSubmodule.getBidRequestData(reqBids, function () {
+ done();
+ }, MOCK_CONFIG, {});
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // getTargetingData
+ // -------------------------------------------------------------------------
+
+ describe('getTargetingData', function () {
+ it('returns vai_vat and vai_act for each ad unit', function () {
+ window[VAI_WINDOW_KEY] = { ...MOCK_VAI };
+ const adUnits = ['ad-unit-1', 'ad-unit-2'];
+ const targeting = paywallsSubmodule.getTargetingData(adUnits, MOCK_CONFIG, {});
+
+ expect(targeting).to.have.property('ad-unit-1');
+ expect(targeting).to.have.property('ad-unit-2');
+ expect(targeting['ad-unit-1']).to.deep.equal({ vai_vat: 'HUMAN', vai_act: 'ACT-1' });
+ expect(targeting['ad-unit-2']).to.deep.equal({ vai_vat: 'HUMAN', vai_act: 'ACT-1' });
+ });
+
+ it('returns empty object when VAI is unavailable', function () {
+ const adUnits = ['ad-unit-1'];
+ const targeting = paywallsSubmodule.getTargetingData(adUnits, MOCK_CONFIG, {});
+ expect(targeting).to.deep.equal({});
+ });
+ });
+});