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"
      }
    }
  }
}
FieldTypeDescription
issStringIssuer — always https://paywalls.net
audStringAudience — always vai
domStringInventory domain the assertion covers (e.g., example.com)
kidStringKey ID for JWS verification. Matches a key in the publisher's JWKS
assertion_jwsStringFull 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"
      }
    }
  }
}
FieldTypeValuesDescription
vatStringHUMAN, AI_AGENT, SHARING, OTHERValidated Actor Type
actStringACT-1, ACT-2, ACT-3Actor Confidence Tier

Actor Type Reference

vat ValueMeaningExamples
HUMANLikely human attentionTypical browser users
AI_AGENTAI system acting on behalf of a userAI assistants, research agents, summarization tools
SHARINGSocial sharing and preview botsLink preview generators, social card fetchers
OTHERNon-human automation not influencing a humanSearch crawlers, AI training, scrapers, monitoring bots, QA tools

Confidence Tier Reference

act ValueMeaningSignal Strength
ACT-1High confidenceKnown pattern match or definitive signal. Strongest assumptions
ACT-2Medium confidenceReasonable signal without corroboration. Standard assumptions
ACT-3Low confidenceUnmatched or unknown. Conservative assumptions

Why Two Scopes?

  • site.ext carries domain provenance (where the impression occurred, how to verify it). DSPs can apply per-domain trust logic.
  • user.ext carries 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 (dom verified) 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"
}
FieldDescription
algSigning algorithm — always EdDSA (Ed25519)
kidKey ID — identifies which key in the JWKS set was used to sign
jkuJWK 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"
}
FieldTypeDescription
vatStringValidated Actor Type
actStringActor Confidence Tier
issStringIssuer identifier
audStringAudience — always vai
domStringInventory domain binding
iatNumberIssued-at timestamp (Unix epoch seconds)
expNumberExpiry timestamp (Unix epoch seconds). Typically 30–120s TTL
kidStringKey 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:

  1. Scheme must be HTTPS — reject http://, data:, or any non-HTTPS scheme
  2. Hostname in jku must match dom in the assertion payload — the JWKS is served from the same domain the assertion claims to represent
  3. 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"
    }
  ]
}
FieldDescription
ktyKey type — OKP (Octet Key Pair) for Ed25519
crvCurve — Ed25519
kidKey ID — match this to the JWS header kid
useKey use — sig (signature)
algAlgorithm — EdDSA
xPublic 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

BehaviorRecommendation
Cache TTL1 hour (or respect Cache-Control header from the JWKS response)
RefreshAsynchronous — serve from cache while refreshing in the background
kid missRefetch JWKS once immediately. If kid still not found, treat assertion as unverifiable
FailureIf JWKS fetch fails, use cached version if available. If no cache, treat assertion as unverifiable
Cache keyThe 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_jws signature (see §2)
  • Cross-reference dom against the inventory domain you observed
  • Add a verification status to your internal quality signals (e.g., vai_verified: true)
  • Use vat/act in your own supply quality scoring — e.g., flag OTHER/ACT-1 traffic 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 vat or act values
  • Strip the assertion_jws field
  • Replace the publisher's jku with 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:

  1. Follow the verification flow in §2.2
  2. Confirm dom matches the inventory domain in site.domain
  3. Use the verified vat/act in 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:

ClassificationSuggested Strategy
HUMAN / ACT-1Premium bid — verified human traffic, highest confidence
HUMAN / ACT-2Standard bid — probable human, good confidence
HUMAN / ACT-3Discounted bid — low confidence human
AI_AGENTEvaluate per-campaign — may have value for certain advertisers
SHARINGLow bid — preview bots, limited ad value
OTHER / ACT-1No bid — known automation with high confidence
No VAI / unverifiedDefault 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 OTHER against 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?

Where do I find more information?


7. Quick-Start Checklist

For SSPs (Minimum Viable)

  • [ ] Confirm site.ext.vai and user.ext.vai pass through your ORTB2 pipeline to DSPs
  • [ ] Verify extension fields are not stripped during bid request serialization
  • [ ] (Optional) Implement assertion_jws verification for supply quality scoring
  • [ ] (Optional) Add vat/act dimensions to publisher reporting

For DSPs

  • [ ] Parse user.ext.vai.vat and user.ext.vai.act from bid requests
  • [ ] Implement assertion_jws verification (§2.2) with JWKS caching (§3)
  • [ ] Validate jku domain binding (§2.3) before fetching JWKS
  • [ ] Add vat/act to reporting pipeline as bid dimensions
  • [ ] (Optional) Implement differential bidding based on actor classification
  • [ ] (Optional) Correlate VAI signals with existing IVT data for validation