VAI SSP/DSP Integration Guide
This guide explains how SSPs (Supply-Side Platforms) and DSPs (Demand-Side Platforms) can consume and verify Validated Actor Inventory (VAI) signals in OpenRTB bid requests.
VAI is a publisher-originated classification signal that identifies the actor type and confidence tier of a page impression. Publishers attach VAI to bid requests via Prebid.js ORTB2 extension fields. Assertions are cryptographically signed with Ed25519 and can be independently verified by any party in the supply chain using the publisher's public keys.
No registration, API key, or Paywalls account is required to verify VAI assertions. The verification keys are hosted on the publisher's own domain — exactly like ads.txt.
1. Signal Location in OpenRTB
VAI signals are split across two ORTB2 scopes in bid requests:
site.ext.vai — Domain Provenance
Contains the assertion metadata and the full signed JWS token. These fields describe where the impression occurred and provide cryptographic proof.
{
"site": {
"domain": "example.com",
"ext": {
"vai": {
"iss": "https://paywalls.net",
"aud": "vai",
"dom": "example.com",
"kid": "2026-02-a1b2c3",
"assertion_jws": "eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMDItYTFiMmMzIiwiamt1IjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9wdy9qd2tzLmpzb24ifQ.eyJ2YXQiOiJIVU1BTiIsImFjdCI6IkFDVC0xIiwiaXNzIjoiaHR0cHM6Ly9wYXl3YWxscy5uZXQiLCJhdWQiOiJ2YWkiLCJkb20iOiJleGFtcGxlLmNvbSIsImlhdCI6MTcwOTMwMDAwMCwiZXhwIjoxNzA5MzAwMDYwLCJraWQiOiIyMDI2LTAyLWExYjJjMyJ9.SIGNATURE"
}
}
}
}
| Field | Type | Description |
|---|---|---|
iss | String | Issuer — always https://paywalls.net |
aud | String | Audience — always vai |
dom | String | Inventory domain the assertion covers (e.g., example.com) |
kid | String | Key ID for JWS verification. Matches a key in the publisher's JWKS |
assertion_jws | String | Full JWS compact serialization — the signed, verifiable token |
user.ext.vai — Actor Classification
Contains the classification result — who or what is consuming the page.
{
"user": {
"ext": {
"vai": {
"vat": "HUMAN",
"act": "ACT-1"
}
}
}
}
| Field | Type | Values | Description |
|---|---|---|---|
vat | String | HUMAN, AI_AGENT, SHARING, OTHER | Validated Actor Type |
act | String | ACT-1, ACT-2, ACT-3 | Actor Confidence Tier |
Actor Type Reference
vat Value | Meaning | Examples |
|---|---|---|
HUMAN | Likely human attention | Typical browser users |
AI_AGENT | AI system acting on behalf of a user | AI assistants, research agents, summarization tools |
SHARING | Social sharing and preview bots | Link preview generators, social card fetchers |
OTHER | Non-human automation not influencing a human | Search crawlers, AI training, scrapers, monitoring bots, QA tools |
Confidence Tier Reference
act Value | Meaning | Signal Strength |
|---|---|---|
ACT-1 | High confidence | Known pattern match or definitive signal. Strongest assumptions |
ACT-2 | Medium confidence | Reasonable signal without corroboration. Standard assumptions |
ACT-3 | Low confidence | Unmatched or unknown. Conservative assumptions |
Why Two Scopes?
site.extcarries domain provenance (where the impression occurred, how to verify it). DSPs can apply per-domain trust logic.user.extcarries actor classification (who/what is on the page). DSPs can apply per-actor bidding logic.- This separation lets demand-side algorithms treat each dimension independently — e.g., bid on a trusted domain (
domverified) even when the actor confidence is medium (ACT-2).
2. Assertion Verification
The assertion_jws field is a JWS compact serialization signed with Ed25519 (EdDSA). Any party in the supply chain can verify it — no relationship with Paywalls required.
2.1 JWS Structure
The token has three base64url-encoded parts: HEADER.PAYLOAD.SIGNATURE
Header:
{
"alg": "EdDSA",
"kid": "2026-02-a1b2c3",
"jku": "https://example.com/pw/jwks.json"
}
| Field | Description |
|---|---|
alg | Signing algorithm — always EdDSA (Ed25519) |
kid | Key ID — identifies which key in the JWKS set was used to sign |
jku | JWK Set URL (RFC 7515 §4.1.2) — where to fetch the publisher's public keys |
Payload:
{
"vat": "HUMAN",
"act": "ACT-1",
"iss": "https://paywalls.net",
"aud": "vai",
"dom": "example.com",
"iat": 1709300000,
"exp": 1709300060,
"kid": "2026-02-a1b2c3"
}
| Field | Type | Description |
|---|---|---|
vat | String | Validated Actor Type |
act | String | Actor Confidence Tier |
iss | String | Issuer identifier |
aud | String | Audience — always vai |
dom | String | Inventory domain binding |
iat | Number | Issued-at timestamp (Unix epoch seconds) |
exp | Number | Expiry timestamp (Unix epoch seconds). Typically 30–120s TTL |
kid | String | Key ID (matches the header kid) |
2.2 Verification Flow
1. Decode the JWS header (base64url-decode the first segment)
2. Extract `kid` and `jku` from the header
3. Validate the `jku` URL (see §2.3 — Domain Binding)
4. Fetch the JWKS: GET {jku}
5. Find the key in the JWKS where `kid` matches the header's `kid`
6. Verify the Ed25519 signature over HEADER.PAYLOAD using the public key
7. Parse the payload and validate claims:
a. `dom` matches the OpenRTB `site.domain`
b. current time < `exp` (with ≤15s clock skew tolerance)
c. `aud` equals "vai"
d. `vat` is one of: HUMAN, AI_AGENT, SHARING, OTHER
e. `act` is one of: ACT-1, ACT-2, ACT-3
2.3 Domain Binding — jku Validation
Before fetching the JWKS, validate the jku URL to prevent key-substitution attacks:
- Scheme must be HTTPS — reject
http://,data:, or any non-HTTPS scheme - Hostname in
jkumust matchdomin the assertion payload — the JWKS is served from the same domain the assertion claims to represent - Path should be
/pw/jwks.json— this is the canonical convention; SSPs MAY enforce this as an additional check
Why this works: If an attacker mints a fake assertion for nytimes.com with jku: "https://evil.com/pw/jwks.json", validation fails at step 2 because evil.com ≠ nytimes.com. The attacker cannot control https://nytimes.com/pw/jwks.json, so a valid jku for that domain is unforgeable.
This mirrors the trust model of ads.txt — the domain is the trust anchor.
2.4 JWKS Response Format
GET https://example.com/pw/jwks.json returns:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"kid": "2026-02-a1b2c3",
"use": "sig",
"alg": "EdDSA",
"x": "BASE64URL_PUBLIC_KEY_32_BYTES"
}
]
}
| Field | Description |
|---|---|
kty | Key type — OKP (Octet Key Pair) for Ed25519 |
crv | Curve — Ed25519 |
kid | Key ID — match this to the JWS header kid |
use | Key use — sig (signature) |
alg | Algorithm — EdDSA |
x | Public key material (base64url-encoded, 32 bytes) |
The JWKS MAY contain multiple keys during key rotation periods. Always match by kid.
2.5 Python Example — Verification
Python example using cryptography library:
import base64, json, hashlib
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
def verify_vai_assertion(assertion_jws: str, site_domain: str) -> dict | None:
"""
Verify a VAI assertion JWS. Returns the payload dict if valid, None otherwise.
"""
# 1. Split JWS into parts
parts = assertion_jws.split('.')
if len(parts) != 3:
return None
header_b64, payload_b64, signature_b64 = parts
# 2. Decode header
header = json.loads(base64url_decode(header_b64))
kid = header.get('kid')
jku = header.get('jku')
alg = header.get('alg')
if alg != 'EdDSA':
return None # unsupported algorithm
# 3. Validate jku (domain binding)
if not jku or not jku.startswith('https://'):
return None
# Decode payload to get dom for cross-reference
payload = json.loads(base64url_decode(payload_b64))
dom = payload.get('dom')
from urllib.parse import urlparse
jku_host = urlparse(jku).hostname
if jku_host != dom:
return None # domain mismatch — possible key substitution
# 4. Fetch JWKS (use cached version if available — see §3)
jwks = fetch_jwks_cached(jku)
if not jwks:
return None
# 5. Find key by kid
key_data = next((k for k in jwks['keys'] if k.get('kid') == kid), None)
if not key_data:
# kid not found — refetch JWKS once (handles key rotation)
jwks = fetch_jwks(jku, force_refresh=True)
key_data = next((k for k in jwks['keys'] if k.get('kid') == kid), None)
if not key_data:
return None
# 6. Verify Ed25519 signature
public_key_bytes = base64url_decode(key_data['x'])
public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes)
signed_data = f"{header_b64}.{payload_b64}".encode('ascii')
signature = base64url_decode(signature_b64)
try:
public_key.verify(signature, signed_data)
except Exception:
return None # signature invalid
# 7. Validate claims
import time
now = time.time()
if payload.get('exp', 0) < now - 15: # 15s clock skew tolerance
return None # expired
if payload.get('aud') != 'vai':
return None
if dom != site_domain:
return None # domain doesn't match bid request
if payload.get('vat') not in ('HUMAN', 'AI_AGENT', 'SHARING', 'OTHER'):
return None
if payload.get('act') not in ('ACT-1', 'ACT-2', 'ACT-3'):
return None
return payload # verified ✓
def base64url_decode(s: str) -> bytes:
"""Decode base64url without padding."""
s += '=' * (4 - len(s) % 4)
return base64.urlsafe_b64decode(s)
2.6 Node.js Example — Verification
const crypto = require('crypto');
async function verifyVaiAssertion(assertionJws, siteDomain) {
const [headerB64, payloadB64, signatureB64] = assertionJws.split('.');
if (!headerB64 || !payloadB64 || !signatureB64) return null;
const header = JSON.parse(base64urlDecode(headerB64));
const payload = JSON.parse(base64urlDecode(payloadB64));
// Validate algorithm
if (header.alg !== 'EdDSA') return null;
// Validate jku — domain binding
const jku = header.jku;
if (!jku || !jku.startsWith('https://')) return null;
const jkuHost = new URL(jku).hostname;
if (jkuHost !== payload.dom) return null;
// Fetch JWKS (use your cache — see §3)
const jwks = await fetchJwksCached(jku);
let keyData = jwks.keys.find(k => k.kid === header.kid);
if (!keyData) {
// kid miss — refetch once
const refreshed = await fetchJwks(jku, /* forceRefresh */ true);
keyData = refreshed.keys.find(k => k.kid === header.kid);
if (!keyData) return null;
}
// Build the Ed25519 public key
const publicKeyObj = crypto.createPublicKey({
key: { kty: 'OKP', crv: 'Ed25519', x: keyData.x },
format: 'jwk'
});
// Verify signature
const signedData = Buffer.from(`${headerB64}.${payloadB64}`, 'ascii');
const signature = Buffer.from(signatureB64, 'base64url');
const valid = crypto.verify(null, signedData, publicKeyObj, signature);
if (!valid) return null;
// Validate claims
const now = Math.floor(Date.now() / 1000);
if ((payload.exp || 0) < now - 15) return null; // expired
if (payload.aud !== 'vai') return null;
if (payload.dom !== siteDomain) return null;
return payload; // verified ✓
}
function base64urlDecode(str) {
return Buffer.from(str, 'base64url').toString('utf-8');
}
3. JWKS Caching
Verifiers MUST NOT block bid paths on JWKS fetches. The JWKS endpoint is a static JSON file — cache aggressively.
Recommended Strategy
| Behavior | Recommendation |
|---|---|
| Cache TTL | 1 hour (or respect Cache-Control header from the JWKS response) |
| Refresh | Asynchronous — serve from cache while refreshing in the background |
kid miss | Refetch JWKS once immediately. If kid still not found, treat assertion as unverifiable |
| Failure | If JWKS fetch fails, use cached version if available. If no cache, treat assertion as unverifiable |
| Cache key | The jku URL (each publisher domain has its own JWKS) |
Key Rotation
Publishers rotate signing keys periodically. During rotation:
- The JWKS endpoint serves both the old and new keys
- New assertions use the new
kid - Old assertions (still within their TTL) reference the old
kid
Your kid-miss refetch handles this automatically — when you see an unknown kid, refetch once and the new key will appear.
Cache Warming
If you regularly receive bid requests from a set of publisher domains, consider pre-warming your JWKS cache by periodically fetching https://{domain}/pw/jwks.json for known domains. This avoids cold-cache latency on the first assertion from a new publisher.
4. What SSPs Do
SSPs sit between publishers and DSPs. Your role in VAI is twofold:
4.1 Pass Signals Through
The minimum viable integration: pass site.ext.vai and user.ext.vai through to DSPs in bid requests unchanged. This requires no verification logic — just ensure the extension fields survive your ORTB2 pipeline.
Most SSPs already preserve unknown ext fields. Confirm that your bid request serialization does not strip site.ext.vai or user.ext.vai.
4.2 Optional: Verify Assertions
For supply quality scoring or signal confidence, SSPs may choose to verify assertions:
- Verify the
assertion_jwssignature (see §2) - Cross-reference
domagainst the inventory domain you observed - Add a verification status to your internal quality signals (e.g.,
vai_verified: true) - Use
vat/actin your own supply quality scoring — e.g., flagOTHER/ACT-1traffic for review
4.3 Optional: Supply Quality Filtering
SSPs can use VAI for supply-side quality enforcement:
- Filter known-bot traffic (
vat: OTHER,act: ACT-1) before it reaches DSPs - Flag low-confidence traffic in your quality dashboards
- Provide VAI-segmented reporting to publishers (fill rate, bid rate, CPM by actor type)
4.4 Do Not Modify
SSPs MUST NOT:
- Rewrite
vatoractvalues - Strip the
assertion_jwsfield - Replace the publisher's
jkuwith an SSP-hosted URL
The assertion is publisher-originated and publisher-signed. SSPs MAY add their own separate quality annotations in their own namespace, but the publisher's VAI fields should pass through intact.
5. What DSPs Do
DSPs are the primary verification and consumption point for VAI. You receive the signals in bid requests and decide how to use them.
5.1 Verify Assertions
For bid pricing confidence, DSPs should verify the assertion_jws:
- Follow the verification flow in §2.2
- Confirm
dommatches the inventory domain insite.domain - Use the verified
vat/actin bidding logic
Unverified assertions (missing, expired, signature invalid) should be treated as unknown — bid at your default rate or skip.
5.2 Differential Bidding
Use vat and act to adjust bid prices:
| Classification | Suggested Strategy |
|---|---|
HUMAN / ACT-1 | Premium bid — verified human traffic, highest confidence |
HUMAN / ACT-2 | Standard bid — probable human, good confidence |
HUMAN / ACT-3 | Discounted bid — low confidence human |
AI_AGENT | Evaluate per-campaign — may have value for certain advertisers |
SHARING | Low bid — preview bots, limited ad value |
OTHER / ACT-1 | No bid — known automation with high confidence |
| No VAI / unverified | Default bid — treat as unclassified |
5.3 Reporting
Add vat/act as dimensions in your reporting pipeline to measure:
- Win rate by actor type
- CPM distribution by confidence tier
- Campaign performance segmented by traffic classification
- IVT correlation (compare VAI
OTHERagainst your existing IVT detection)
6. FAQ
What if site.ext.vai is missing from a bid request?
Nothing breaks. The publisher hasn't enabled VAI, or their Prebid build doesn't include the RTD module. Treat the impression as unclassified and bid at your normal rate. VAI is additive — its absence is not a signal.
What if only user.ext.vai is present without assertion_jws?
The classification is unverifiable. The publisher may have set the fields manually without the signed assertion. You can use the values for analytics/logging but should not trust them for bid pricing without cryptographic verification.
What if the kid is not in the JWKS?
Refetch the JWKS once (handles key rotation). If the kid still isn't present, treat the assertion as unverifiable. This typically means the key has been fully retired and the assertion is stale.
What if the assertion is expired?
Assertions have a 30–120 second TTL. An expired assertion means the classification is stale — it may reflect a different actor or session. Treat it as unverifiable.
Can we cache the verification result instead of verifying every time?
Yes, but cache by assertion_jws value (the full token is a unique identifier). De-duplicate identical assertions within the TTL window. Don't cache by domain alone — each page load produces a different assertion.
Does VAI replace IVT detection?
No. VAI is a publisher-originated traffic classification signal. It complements — but does not replace — MRC-accredited IVT detection (MOAT, IAS, DoubleVerify, etc.). VAI tells you the publisher's classification of who is on the page. IVT systems tell you about fraud and sophisticated invalid traffic. Use both.
What standards does VAI follow?
- JWS signing: RFC 7515 (JSON Web Signature)
- JWKS format: RFC 7517 (JSON Web Key Set)
jkuheader: RFC 7515 §4.1.2 (JWK Set URL)- Ed25519 algorithm: RFC 8037 (CFRG Elliptic Curves for JOSE)
- ORTB2 extension fields: OpenRTB 2.6 §3.2.13, §3.2.20
Where do I find more information?
- VAI Technical Spec — full signal format, signing, and verification details
- VAI Publisher Docs — how publishers implement VAI
- Prebid RTD Module Docs — how the signal gets into bid requests
- RFC 7515 — JWS — the signing standard used
7. Quick-Start Checklist
For SSPs (Minimum Viable)
- [ ] Confirm
site.ext.vaianduser.ext.vaipass through your ORTB2 pipeline to DSPs - [ ] Verify extension fields are not stripped during bid request serialization
- [ ] (Optional) Implement
assertion_jwsverification for supply quality scoring - [ ] (Optional) Add
vat/actdimensions to publisher reporting
For DSPs
- [ ] Parse
user.ext.vai.vatanduser.ext.vai.actfrom bid requests - [ ] Implement
assertion_jwsverification (§2.2) with JWKS caching (§3) - [ ] Validate
jkudomain binding (§2.3) before fetching JWKS - [ ] Add
vat/actto reporting pipeline as bid dimensions - [ ] (Optional) Implement differential bidding based on actor classification
- [ ] (Optional) Correlate VAI signals with existing IVT data for validation