VAI + Prebid.js Pilot Guide

This guide walks you through a phased pilot of Validated Actor Inventory (VAI) with Prebid.js. By the end, you'll have traffic-quality data in your existing analytics and (optionally) pre-auction routing rules that send the right traffic to the right demand partners.

Who this is for: Publisher ad ops teams and developers who use Prebid.js for header bidding.

What you'll need:

  • A Prebid.js build you control (or a wrapper vendor willing to add modules)
  • VAI endpoints live on your domain (see busy ops guide or CDN integration)
  • An analytics tool (GA4, GTM, or a custom pipeline)

Phase 1: Measure — Analytics Adapter

Goal: See how traffic quality affects your yield before changing anything about how you auction.

In this phase, the Paywalls Analytics Adapter emits VAI classification (vai_vat, vai_act) on each Prebid auction into your existing analytics. You slice your own data to answer: "How much more is my verified-human traffic worth?"

1.1 Build Prebid with the Analytics Adapter

Add paywallsAnalyticsAdapter (and optionally paywallsRtdProvider) to your Prebid build:

gulp build --modules=rtdModule,paywallsRtdProvider,paywallsAnalyticsAdapter,appnexusBidAdapter,...

The analytics adapter is self-sufficient — it loads vai.js itself if window.__PW_VAI__ is not already present. You don't need a separate <script> tag.

1.2 Enable the Adapter

Add this to your Prebid configuration:

pbjs.enableAnalytics([{
  provider: 'paywalls',
  options: {
    output: 'dataLayer',          // 'gtag' | 'dataLayer' | 'callback'
    scriptUrl: '/pw/vai.js',      // same-origin (default)
    samplingRate: 1.0             // 1.0 = every page; 0.1 = 10% sample
  }
}]);

Output Modes

Choose the output that matches your analytics stack:

Google Analytics 4 (gtag):

pbjs.enableAnalytics([{
  provider: 'paywalls',
  options: {
    output: 'gtag'
    // Requires the GA4 snippet (gtag.js) to be loaded on the page.
    // Fires: gtag('event', 'vai_auction', { vai_vat: 'HUMAN', vai_act: 'ACT-1' })
  }
}]);

Google Tag Manager (dataLayer):

pbjs.enableAnalytics([{
  provider: 'paywalls',
  options: {
    output: 'dataLayer'
    // Pushes: { event: 'vai_auction', vai_vat: 'HUMAN', vai_act: 'ACT-1' }
    // In GTM, create a Custom Event trigger on 'vai_auction'
  }
}]);

Custom callback:

pbjs.enableAnalytics([{
  provider: 'paywalls',
  options: {
    output: 'callback',
    callback: function (metrics) {
      // Send to your analytics pipeline
      console.log(metrics);
      // { vai_vat: 'HUMAN', vai_act: 'ACT-1' }
    }
  }
}]);

Parameters Reference

ParameterTypeDefaultDescription
outputString'callback'Output mode: 'gtag', 'dataLayer', or 'callback'
scriptUrlString'/pw/vai.js'URL of the VAI loader script
samplingRateNumber1.0Fraction of page views that emit (0.0–1.0)
callbackFunctionnullCalled with metrics when output is 'callback'

1.3 Set Up GA4 Reporting

Once the adapter is emitting, create a GA4 exploration to see the impact:

  1. Go to GA4 → Explore → Blank
  2. Dimensions: Add vai_vat and vai_act (these appear as custom event parameters)
  3. Metrics: Add any ad performance metrics you track (revenue, impressions, etc.)
  4. Technique: Free-form table
  5. Rows: vai_vat, then vai_act
  6. Values: Your metrics

First-time setup: GA4 doesn't automatically register custom event parameters. Go to Admin → Data display → Custom definitions → Create custom dimension and add vai_vat and vai_act as event-scoped dimensions. Allow 24–48 hours for data to populate.

What to look for:

  • CPM by actor type: Is HUMAN/ACT-1 traffic earning more than other segments?
  • Fill rate by confidence tier: Are ACT-3 impressions seeing lower fill?
  • Timeout rate: Do certain actor types correlate with higher bidder timeouts?

1.4 Set Up GTM (if using dataLayer output)

If you chose dataLayer output and want to route the event to GA4 via GTM:

  1. Create a Custom Event Trigger:

    • Trigger type: Custom Event
    • Event name: vai_auction
  2. Create Data Layer Variables:

    • Variable name: vai_vat → Data Layer Variable Name: vai_vat
    • Variable name: vai_act → Data Layer Variable Name: vai_act
  3. Create a GA4 Event Tag:

    • Tag type: Google Analytics: GA4 Event
    • Event name: vai_auction
    • Event parameters: vai_vat = {{vai_vat}}, vai_act = {{vai_act}}
    • Trigger: The vai_auction custom event trigger
  4. Publish the GTM container.

1.5 Validate

Open your site in a browser and check the DevTools Console for:

[PaywallsAnalytics] emitting metrics via dataLayer: {vai_vat: "HUMAN", vai_act: "ACT-1"}

In GA4 Realtime (or GTM Preview mode), confirm the vai_auction event is firing with the expected parameters.


Phase 2: Route + Signal — RTD Module

Goal: Use VAI classification to control which demand partners see which traffic. VAI signals also flow into bid requests as ORTB2 data for SSPs/DSPs.

2.1 Add the RTD Module to Your Build

If you didn't include it in Phase 1:

gulp build --modules=rtdModule,paywallsRtdProvider,paywallsAnalyticsAdapter,appnexusBidAdapter,...

2.2 Configure the RTD Module

pbjs.setConfig({
  realTimeData: {
    auctionDelay: 500,          // max ms to wait for all RTD submodules
    dataProviders: [
      {
        name: 'paywalls',
        waitForIt: true,        // hold auction for VAI
        params: {
          scriptUrl: '/pw/vai.js'   // same-origin (default)
        }
      }
    ]
  }
});

Parameters Reference

ParameterTypeDefaultDescription
nameStringMust be 'paywalls'
waitForItBooleanfalseHold auction until VAI is ready (recommended with auctionDelay)
params.scriptUrlString'/pw/vai.js'URL of the VAI loader script
params.waitForItNumber100Max ms to wait for VAI before releasing the auction

What happens under the hood:

  1. Module checks window.__PW_VAI__ and localStorage for an existing VAI payload
  2. If found and unexpired → injects ORTB2 immediately (fast path)
  3. If not found → injects vai.js via loadExternalScript, waits up to waitForIt ms
  4. On timeout → proceeds without enrichment (graceful degradation — never blocks the auction)

2.3 ORTB2 Output

Once configured, every bid request automatically includes:

site.ext.vai — domain provenance and signed assertion:

{
  "iss": "https://paywalls.net",
  "aud": "vai",
  "dom": "example.com",
  "kid": "2026-02-a1b2c3",
  "assertion_jws": "eyJhbGciOiJFZERTQSIs..."
}

user.ext.vai — actor classification:

{
  "vat": "HUMAN",
  "act": "ACT-1"
}

SSPs and DSPs that read these fields can verify the assertion and use the classification in their bidding logic. SSPs that don't know about VAI simply ignore the extension fields — nothing breaks.

2.4 GAM Targeting

The module also sets key-value pairs on every ad unit for Google Ad Manager:

KeyExample ValueDescription
vai_vatHUMANActor type
vai_actACT-1Confidence tier

These are available via pbjs.getAdserverTargeting(). You can build GAM line items that target specific actor types — e.g., serve a direct-sold campaign only to HUMAN/ACT-1 impressions.

2.5 Configure Activity Controls for Traffic Routing

This is where you get the most immediate value. Prebid Activity Controls let you control which demand partners participate in each auction based on VAI classification.

These rules live in your Prebid config — not inside the RTD module. You know your demand stack; VAI provides the signal.

Example: Full routing configuration

pbjs.setConfig({
  allowActivities: {

    // --- Traffic routing by VAI classification ---
    fetchBids: {
      rules: [
        {
          // Known bot traffic (OTHER/ACT-1): skip auction entirely
          condition: function (params) {
            var vai = window.__PW_VAI__;
            return params.componentType === 'bidder'
              && vai && vai.vat === 'OTHER' && vai.act === 'ACT-1';
          },
          allow: false
        },
        {
          // AI agent traffic: only allow backfill partners
          condition: function (params) {
            var vai = window.__PW_VAI__;
            if (!vai || vai.vat !== 'AI_AGENT') return false;
            if (params.componentType !== 'bidder') return false;
            // Your backfill bidders — adjust to your demand stack
            var backfill = ['bidderX', 'bidderY'];
            return !backfill.includes(params.componentName);
          },
          allow: false
        },
        {
          // Low-confidence human (ACT-3): restrict to backfill
          condition: function (params) {
            var vai = window.__PW_VAI__;
            if (!vai || vai.vat !== 'HUMAN' || vai.act !== 'ACT-3') return false;
            if (params.componentType !== 'bidder') return false;
            var backfill = ['bidderX', 'bidderY'];
            return !backfill.includes(params.componentName);
          },
          allow: false
        }
      ]
    },

    // --- Allow the RTD module to load vai.js ---
    loadExternalScript: {
      rules: [
        {
          condition: function (params) {
            return params.componentName === 'paywalls';
          },
          allow: true
        }
      ]
    }
  }
});

Routing Strategy Reference

ClassificationSuggested ActionRationale
HUMAN / ACT-1Full auction, all demand; higher floorsVerified human — maximize yield
HUMAN / ACT-2Full auction, standard floorsProbable human — treat normally
HUMAN / ACT-3Backfill demand onlyLow confidence — protect premium relationships
AI_AGENTBackfill or dedicated AI demandNot human inventory; may have value
SHARINGBackfill or suppressPreview bots — limited ad value
OTHER / ACT-1Skip auction entirelyKnown bot — no ad value
No VAIFull auction (graceful degradation)Unclassified — don't penalize

Customize these rules. You know which SSPs are your premium partners, which are backfill, and which handle non-standard traffic. Replace bidderX/bidderY with your actual bidder codes.

2.6 Validate

  1. Open your site and check the DevTools Console for:

    [PaywallsRtd] merged ORTB2 fragments. vat=HUMAN act=ACT-1
    
  2. Check pbjs.getConfig('ortb2') in the console — you should see site.ext.vai and user.ext.vai.

  3. Check pbjs.getAdserverTargeting() — each ad unit should have vai_vat and vai_act.

  4. In your GAM reports, filter by the vai_vat key to confirm targeting keys are flowing.


Hosting Modes

VAI supports two hosting modes for the loader script:

Publisher-hosted (recommended for production)

The script is served from your own domain. Use the default relative path:

scriptUrl: '/pw/vai.js'     // default — same-origin

Benefits:

  • Same-origin requests — no CORS configuration
  • The assertion's dom claim matches your inventory domain
  • SSPs can verify domain provenance (the jku in the JWS header points to your domain's JWKS)
  • No dependency on external services at auction time

Requires: CDN integration or edge logic serving /pw/vai.js, /pw/vai.json, and /pw/jwks.json on your domain.

Paywalls-hosted (for demos and pilots)

The script is served from https://paywalls.net:

scriptUrl: 'https://paywalls.net/pw/vai.js'

Trade-offs:

  • Easier to start — no CDN integration needed
  • The dom claim reflects paywalls.net, not your inventory domain
  • SSPs cannot verify publisher domain provenance
  • Cross-origin request (CORS)
  • Requires paywalls.net configuration before usage

Use this for: Initial demos, proof-of-concept pilots, or testing before setting up publisher-hosted VAI.

Manual <script> tag vs. adapter loading

ApproachWho loads vai.jsWhen to use
Analytics adapter / RTD modulePrebid (automatic)Production — no manual tags needed
<script src="/pw/vai.js">Publisher HTMLDemos, pilots, or when you can't rebuild Prebid

If you have both a <script> tag and the RTD module, the module detects window.__PW_VAI__ and uses the existing payload — no duplicate injection.


Activity Controls — loadExternalScript

Both the analytics adapter and RTD module use Prebid's loadExternalScript to inject vai.js. If your Prebid configuration restricts external script loading by default, you need to allow the paywalls component:

pbjs.setConfig({
  allowActivities: {
    loadExternalScript: {
      default: false,
      rules: [{
        condition: function (params) {
          return params.componentName === 'paywalls';
        },
        allow: true
      }]
    }
  }
});

If you prefer not to allow dynamic script loading at all, load vai.js manually with a <script> tag — both modules detect window.__PW_VAI__ and skip injection.


Troubleshooting

VAI is UNKNOWN in analytics

Likely cause: vai.js failed to load or hasn't finished executing by the time the auction ends.

Fix:

  • Check that /pw/vai.js returns a 200 response (open it directly in a browser)
  • Check the console for [PaywallsAnalytics] injecting vai.js — if missing, the adapter didn't attempt injection
  • Check CSP: script-src must allow same-origin or https://paywalls.net (if Paywalls-hosted)
  • If using the RTD module, increase auctionDelay (e.g., to 500ms)

loadExternalScript denied

Likely cause: Activity Controls block external scripts.

Fix: Add the loadExternalScript allow rule for paywalls (see Activity Controls section above), or load vai.js manually with a <script> tag.

VAI loaded but ORTB2 is empty

Likely cause: The RTD module isn't included in your Prebid build.

Fix:

  • Confirm your build includes rtdModule and paywallsRtdProvider
  • Check the console for [PaywallsRtd] log messages
  • Verify pbjs.installedModules includes 'paywallsRtdProvider'

VAI timeout — auction proceeds without enrichment

Expected behavior. The RTD module never blocks the auction. If vai.js takes longer than waitForIt (default 100ms), the auction proceeds without VAI.

To reduce timeouts:

  • Load vai.js with a <script> tag earlier in the page — the module detects window.__PW_VAI__ and skips injection (fast path)
  • Increase waitForIt in params (but keep it within auctionDelay)
  • Check network performance of /pw/vai.js — it should be fast (edge-served, no heavy computation)

GAM keys not appearing

Likely cause: getTargetingData requires VAI to be available. If it timed out, targeting keys won't be set.

Fix: Same as timeout above — ensure VAI loads before the auction completes. Check pbjs.getAdserverTargeting() in the console.

dom in the assertion doesn't match my domain

Likely cause: Using Paywalls-hosted VAI (scriptUrl: 'https://paywalls.net/pw/vai.js').

Fix: Switch to publisher-hosted VAI for production. The dom claim is bound to the domain serving vai.js — Paywalls-hosted mode binds to paywalls.net, not your inventory domain.


Full Configuration Example

A complete Prebid configuration with both modules and Activity Controls:

var BACKFILL_BIDDERS = ['bidderX', 'bidderY'];

pbjs.setConfig({
  // --- RTD Module ---
  realTimeData: {
    auctionDelay: 500,
    dataProviders: [{
      name: 'paywalls',
      waitForIt: true,
      params: {
        scriptUrl: '/pw/vai.js'
      }
    }]
  },

  // --- Activity Controls ---
  allowActivities: {
    // Allow vai.js loading
    loadExternalScript: {
      rules: [{
        condition: function (params) {
          return params.componentName === 'paywalls';
        },
        allow: true
      }]
    },

    // Traffic routing
    fetchBids: {
      rules: [{
        // Known bots: no auction
        condition: function (params) {
          var vai = window.__PW_VAI__;
          return params.componentType === 'bidder'
            && vai && vai.vat === 'OTHER' && vai.act === 'ACT-1';
        },
        allow: false
      }, {
        // AI agents: backfill only
        condition: function (params) {
          var vai = window.__PW_VAI__;
          if (!vai || vai.vat !== 'AI_AGENT') return false;
          if (params.componentType !== 'bidder') return false;
          return !BACKFILL_BIDDERS.includes(params.componentName);
        },
        allow: false
      }]
    }
  }
});

// --- Analytics Adapter ---
pbjs.enableAnalytics([{
  provider: 'paywalls',
  options: {
    output: 'dataLayer',
    samplingRate: 1.0
  }
}]);

Next Steps

  • Compare segments: After a week of data, compare CPM, fill rate, and timeout rate across vai_vat / vai_act segments in GA4.
  • Tune routing rules: Start conservative — route only OTHER/ACT-1 to no-auction. Expand rules as you validate the data.
  • Share with SSPs: Once you're confident in the signal, let your SSP contacts know you're sending VAI in ORTB2. Point them to the SSP/DSP integration guide.
  • Move to publisher-hosted: If you started with Paywalls-hosted mode, set up CDN integration for production deployment with domain provenance.

Links