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.jsitself ifwindow.__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
| Parameter | Type | Default | Description |
|---|---|---|---|
output | String | 'callback' | Output mode: 'gtag', 'dataLayer', or 'callback' |
scriptUrl | String | '/pw/vai.js' | URL of the VAI loader script |
samplingRate | Number | 1.0 | Fraction of page views that emit (0.0–1.0) |
callback | Function | null | Called 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:
- Go to GA4 → Explore → Blank
- Dimensions: Add
vai_vatandvai_act(these appear as custom event parameters) - Metrics: Add any ad performance metrics you track (revenue, impressions, etc.)
- Technique: Free-form table
- Rows:
vai_vat, thenvai_act - 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_vatandvai_actas event-scoped dimensions. Allow 24–48 hours for data to populate.
What to look for:
- CPM by actor type: Is
HUMAN/ACT-1traffic earning more than other segments? - Fill rate by confidence tier: Are
ACT-3impressions 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:
Create a Custom Event Trigger:
- Trigger type: Custom Event
- Event name:
vai_auction
Create Data Layer Variables:
- Variable name:
vai_vat→ Data Layer Variable Name:vai_vat - Variable name:
vai_act→ Data Layer Variable Name:vai_act
- Variable name:
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_auctioncustom event trigger
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
| Parameter | Type | Default | Description |
|---|---|---|---|
name | String | — | Must be 'paywalls' |
waitForIt | Boolean | false | Hold auction until VAI is ready (recommended with auctionDelay) |
params.scriptUrl | String | '/pw/vai.js' | URL of the VAI loader script |
params.waitForIt | Number | 100 | Max ms to wait for VAI before releasing the auction |
What happens under the hood:
- Module checks
window.__PW_VAI__andlocalStoragefor an existing VAI payload - If found and unexpired → injects ORTB2 immediately (fast path)
- If not found → injects
vai.jsvialoadExternalScript, waits up towaitForItms - 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:
| Key | Example Value | Description |
|---|---|---|
vai_vat | HUMAN | Actor type |
vai_act | ACT-1 | Confidence 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
| Classification | Suggested Action | Rationale |
|---|---|---|
HUMAN / ACT-1 | Full auction, all demand; higher floors | Verified human — maximize yield |
HUMAN / ACT-2 | Full auction, standard floors | Probable human — treat normally |
HUMAN / ACT-3 | Backfill demand only | Low confidence — protect premium relationships |
AI_AGENT | Backfill or dedicated AI demand | Not human inventory; may have value |
SHARING | Backfill or suppress | Preview bots — limited ad value |
OTHER / ACT-1 | Skip auction entirely | Known bot — no ad value |
| No VAI | Full 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
Open your site and check the DevTools Console for:
[PaywallsRtd] merged ORTB2 fragments. vat=HUMAN act=ACT-1
Check
pbjs.getConfig('ortb2')in the console — you should seesite.ext.vaianduser.ext.vai.Check
pbjs.getAdserverTargeting()— each ad unit should havevai_vatandvai_act.In your GAM reports, filter by the
vai_vatkey 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
domclaim matches your inventory domain - SSPs can verify domain provenance (the
jkuin 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
domclaim reflectspaywalls.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
| Approach | Who loads vai.js | When to use |
|---|---|---|
| Analytics adapter / RTD module | Prebid (automatic) | Production — no manual tags needed |
<script src="/pw/vai.js"> | Publisher HTML | Demos, 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.jsreturns 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-srcmust allow same-origin orhttps://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
rtdModuleandpaywallsRtdProvider - Check the console for
[PaywallsRtd]log messages - Verify
pbjs.installedModulesincludes'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.jswith a<script>tag earlier in the page — the module detectswindow.__PW_VAI__and skips injection (fast path) - Increase
waitForItin params (but keep it withinauctionDelay) - 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_actsegments in GA4. - Tune routing rules: Start conservative — route only
OTHER/ACT-1to 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
- VAI overview
- VAI busy ops guide — quick script-tag setup without Prebid
- VAI technical spec — full signal format, signing, and verification
- CDN integration — deploying edge logic for publisher-hosted VAI
- SSP/DSP integration guide — for your demand partners
- Prebid RTD Module docs (paywallsRtdProvider)
- Prebid Activity Controls