<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aleksander Sekowski</title>
    <description>The latest articles on DEV Community by Aleksander Sekowski (@aleksuix).</description>
    <link>https://dev.to/aleksuix</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3866734%2F559a0a69-3c28-49e2-91c7-503810b941ba.png</url>
      <title>DEV Community: Aleksander Sekowski</title>
      <link>https://dev.to/aleksuix</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aleksuix"/>
    <language>en</language>
    <item>
      <title>I ran the AdCP conformance suite against my own agent. It found eight bugs.</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Sun, 09 Aug 2026 16:09:01 +0000</pubDate>
      <link>https://dev.to/aleksuix/i-ran-the-adcp-conformance-suite-against-my-own-agent-it-found-eight-bugs-54bm</link>
      <guid>https://dev.to/aleksuix/i-ran-the-adcp-conformance-suite-against-my-own-agent-it-found-eight-bugs-54bm</guid>
      <description>&lt;p&gt;Agentic advertising picked up a governance layer this year. PubMatic shipped one on AgenticOS: platform-wide constraints plus per-buyer policies, pre-approved creative and inventory libraries, human approval workflows with authenticated approvers, full audit logging, and drift detection that flags anomalous agent behaviour before it executes. For buyers on the Ad Context Protocol, it runs an independent check on transactions touching the platform, whichever agent initiated them.&lt;/p&gt;

&lt;p&gt;Every one of those controls answers the same question: &lt;strong&gt;was this agent allowed to do that?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;None of them answers a different one: &lt;strong&gt;is the thing it produced actually valid?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Budget caps, inventory allowlists, and geo constraints say nothing about whether the VAST document that ends up in the bid response has a valid &lt;code&gt;Impression&lt;/code&gt; node, HTTPS media files, or a wrapper chain under the depth limit. Drift detection is anomaly detection over agent behaviour, not schema conformance. Policy governance and payload conformance are different layers, and the second one has been mostly empty.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://vastlint.org" rel="noopener noreferrer"&gt;vastlint&lt;/a&gt;, a VAST validator, and it exposes an AdCP governance surface: create content standards, calibrate creative against them before launch, validate that delivered creative met them. So I'd been quietly claiming to fill that gap.&lt;/p&gt;

&lt;p&gt;Then I actually ran the conformance suite against it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Declaration is not conformance
&lt;/h2&gt;

&lt;p&gt;An AdCP agent declares what it supports in &lt;code&gt;get_adcp_capabilities&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"supported_protocols"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"governance"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"specialisms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"content-standards"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a claim, not a credential. The protocol treats it as one: declaring a protocol or specialism commits you to passing the matching storyboards. A storyboard is a scripted sequence of tool calls with a response-schema assertion on every step, plus cross-step invariants. The runner reads your declaration, selects the storyboards it obligates, and executes them against your live endpoint.&lt;/p&gt;

&lt;p&gt;The whole thing ships as a CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @adcp/sdk@latest storyboard run https://your-agent.example.com/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--test-kit&lt;/span&gt; dist/compliance/3.1.1/test-kits/acme-outdoor.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I expected a clean run. The tools worked. I'd tested them. I'd been using them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eight things it found
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Unauthenticated calls returned HTTP 200.&lt;/strong&gt; Protected tools answered an anonymous caller with a 200 carrying &lt;code&gt;{"adcp_error": {"code": "REQUIRES_AUTH"}}&lt;/code&gt; in the body. There was even a comment explaining why, so runners could branch on the error code instead of treating it as a protocol failure. The security baseline is explicit that anything other than 401 or 403 is non-conformant, and 401 has to carry &lt;code&gt;WWW-Authenticate&lt;/code&gt;. My reasoning had been confidently backwards, in writing, for months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The creative library was readable anonymously.&lt;/strong&gt; &lt;code&gt;list_creatives&lt;/code&gt; was excluded from the protected set because it always returned an empty collection, so what was the harm. The spec designates it the &lt;em&gt;default probe target&lt;/em&gt; for exactly this test. It was the one endpoint guaranteed to be checked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A capability filter could violate its own schema.&lt;/strong&gt; &lt;code&gt;get_adcp_capabilities&lt;/code&gt; takes an optional &lt;code&gt;protocols&lt;/code&gt; filter. Mine intersected it with what the agent supports, so filtering for something unsupported returned &lt;code&gt;"supported_protocols": []&lt;/code&gt;. The response schema sets &lt;code&gt;minItems: 1&lt;/code&gt;. The filter is meant to scope which capability blocks come back, not to narrow the declaration of what the agent implements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. No release-precision version anywhere.&lt;/strong&gt; The protocol moved to &lt;code&gt;MAJOR.MINOR&lt;/code&gt; version negotiation, with sellers advertising &lt;code&gt;adcp.supported_versions&lt;/code&gt; and echoing &lt;code&gt;adcp_version&lt;/code&gt; on each response. I emitted neither. Advisory at 3.1, required at 3.2, so this was a deadline I hadn't noticed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. A response shape that had drifted.&lt;/strong&gt; &lt;code&gt;list_creatives&lt;/code&gt; was missing the required &lt;code&gt;query_summary&lt;/code&gt; and &lt;code&gt;pagination.has_more&lt;/code&gt;, and returned two pagination fields the schema forbids. It had been valid once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. An enum hole that broke reads permanently.&lt;/strong&gt; &lt;code&gt;create_content_standards&lt;/code&gt; accepted a scope with any channel value. Store &lt;code&gt;{"channels": ["video"]}&lt;/code&gt;, which is not in the AdCP channel enum, and every subsequent &lt;code&gt;list_content_standards&lt;/code&gt; for that brand fails schema validation forever, because the bad value gets replayed on read. Write-side validation was missing, so one bad request poisoned a tenant. This one was found by accident: a record from an old test run was sitting in my local database and blocked the first run I attempted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. A table that grew without bound.&lt;/strong&gt; Idempotency replay rows had a TTL that was only ever checked on read. Nothing deleted expired rows. Fine at my traffic, until I registered for a compliance heartbeat that mutates with a fresh idempotency key every hour, forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. A plain GET that never terminated.&lt;/strong&gt; This is my favourite. &lt;code&gt;GET /mcp&lt;/code&gt; opened the legacy MCP HTTP+SSE stream: correct behaviour for that transport, 200 with &lt;code&gt;text/event-stream&lt;/code&gt;, keepalive pings every 25 seconds, connection held open indefinitely. The conformance runner never touched it, because it POSTs over Streamable HTTP. The registry's capability crawler does issue a plain GET, and reads the response to completion. So it hung, timed out, and recorded the agent as offline while the compliance suite reported everything passing on the same URL. Two probes, two transports, opposite verdicts.&lt;/p&gt;

&lt;p&gt;The fix was to serve SSE only to clients that actually negotiate it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;accept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Accept&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/event-stream&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handleMcpSse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;mcpEndpointDescriptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// bounded JSON, ~100ms&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I take from this
&lt;/h2&gt;

&lt;p&gt;Every one of these was invisible to me. Not hard to find, not subtle, invisible. My own tests passed because they tested the behaviour I'd implemented, against the shape I believed was correct. Six of the eight were failures of &lt;em&gt;my belief about the spec&lt;/em&gt;, not failures of code against my intent. Tests you write yourself cannot catch that class of error, because the same misunderstanding authors both the code and the test.&lt;/p&gt;

&lt;p&gt;That is the entire argument for machine-checkable conformance, and I've been making it in the abstract for a while. Making it about my own code is considerably more persuasive, mostly to me.&lt;/p&gt;

&lt;p&gt;It generalises past this one agent. The advertising stack runs on specs that are enormous, widely implemented, and almost entirely unenforced. VAST, OpenRTB, adagents.json, brand.json: all of them documents people implement from, none of them documents anything checks you against, unless somebody builds the checker. Where a conformance suite exists, running it against a well-intentioned implementation still finds eight things. Where one doesn't exist, nobody finds anything, and everyone assumes it's fine.&lt;/p&gt;

&lt;p&gt;The governance layer being built for agentic buying inherits that problem directly. You can enforce that an agent stayed inside its budget and its allowlist and still ship a creative that no player will render, because nothing in the policy layer parses the payload. Those are complementary jobs and only one of them is currently being built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it yourself
&lt;/h2&gt;

&lt;p&gt;If you operate an AdCP agent, the suite takes minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# see what your declarations actually obligate you to&lt;/span&gt;
npx @adcp/sdk@latest storyboard show &lt;span class="nt"&gt;--specialism&lt;/span&gt; content-standards

&lt;span class="c"&gt;# run it&lt;/span&gt;
npx @adcp/sdk@latest storyboard run &amp;lt;your-url&amp;gt; &lt;span class="nt"&gt;--test-kit&lt;/span&gt; &amp;lt;kit.yaml&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two traps worth knowing. &lt;code&gt;--test-kit&lt;/code&gt; is required for the security baseline to grade at all, and it's missing from the CLI's help output. Without it the auth phases skip silently and the storyboard fails with "no auth mechanism verified", which reads like an auth bug in your agent when nothing is wrong. And local runs need &lt;code&gt;--allow-http&lt;/code&gt;, which stamps the result as not publishable, so anything you intend to cite has to come from an HTTPS endpoint.&lt;/p&gt;

&lt;p&gt;After the eight fixes, vastlint passes 28 steps with 0 failures: Core Protocol 34 of 34 scenarios, Governance 8 of 8, on the AdCP 3.1.1 bundle. There's a longer writeup of how the grading works and what the content-standards specialism covers &lt;a href="https://vastlint.org/docs/adcp-conformance/" rel="noopener noreferrer"&gt;in the docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I'd genuinely like to know whether other people's agents fare better than mine did. My guess is that the ones nobody has graded are in roughly the same shape, and simply don't know it yet.&lt;/p&gt;

</description>
      <category>advertising</category>
      <category>testing</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Validate OpenRTB Bid Requests in CI With ajv and a Schema You Did Not Have to Write</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Sun, 09 Aug 2026 08:18:38 +0000</pubDate>
      <link>https://dev.to/aleksuix/validate-openrtb-bid-requests-in-ci-with-ajv-and-a-schema-you-did-not-have-to-write-231h</link>
      <guid>https://dev.to/aleksuix/validate-openrtb-bid-requests-in-ci-with-ajv-and-a-schema-you-did-not-have-to-write-231h</guid>
      <description>&lt;p&gt;OpenRTB is a JSON protocol with no official JSON Schema. The IAB publishes the specification as prose and tables, plus a protobuf definition, and that is it. If you want to validate a bid request structurally, you write the schema yourself.&lt;/p&gt;

&lt;p&gt;So most teams have a partial one. It covers the objects they touch, it was correct against whichever version was current when someone wrote it, and it silently accepts everything it does not know about. It works right up until a partner sends a field type you never modelled.&lt;/p&gt;

&lt;p&gt;I generate these from the spec as part of building an OpenRTB linter, and they are published as static files. You can use them without the linter, without Rust, and without me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is there
&lt;/h2&gt;

&lt;p&gt;Thirty-four schemas, request and response, across seventeen versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2.0  2.1  2.2  2.3  2.3.1  2.4  2.5
2.6-202210  2.6-202211  2.6-202303  2.6-202309  2.6-202402
2.6-202409  2.6-202501  2.6-202505  2.6-202606
3.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each one is JSON Schema draft 2020-12 with a resolvable &lt;code&gt;$id&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://rtblint.org/schemas/openrtb-2.6-202606-bid-request.schema.json | jq &lt;span class="s1"&gt;'{$schema, $id, title, required}'&lt;/span&gt;
&lt;span class="go"&gt;{
&lt;/span&gt;&lt;span class="gp"&gt;  "$&lt;/span&gt;schema&lt;span class="s2"&gt;": "&lt;/span&gt;https://json-schema.org/draft/2020-12/schema&lt;span class="s2"&gt;",
&lt;/span&gt;&lt;span class="gp"&gt;  "$&lt;/span&gt;&lt;span class="s2"&gt;id"&lt;/span&gt;: &lt;span class="s2"&gt;"https://rtblint.org/schemas/openrtb-2.6-202606-bid-request.schema.json"&lt;/span&gt;,
&lt;span class="go"&gt;  "title": "OpenRTB bid request (2.6-202606)",
  "required": ["id", "imp"]
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;32 named definitions in that one (&lt;code&gt;App&lt;/code&gt;, &lt;code&gt;Audio&lt;/code&gt;, &lt;code&gt;Banner&lt;/code&gt;, &lt;code&gt;Channel&lt;/code&gt;, &lt;code&gt;Content&lt;/code&gt;, &lt;code&gt;DOOH&lt;/code&gt;, &lt;code&gt;Data&lt;/code&gt;, &lt;code&gt;Deal&lt;/code&gt;, &lt;code&gt;Device&lt;/code&gt;, &lt;code&gt;DurFloors&lt;/code&gt;, and so on), so you can &lt;code&gt;$ref&lt;/code&gt; individual objects rather than pulling the whole request in.&lt;/p&gt;

&lt;p&gt;The version matters more than you might expect. OpenRTB 2.6 ships as dated snapshots that add and occasionally rename fields, so "2.6" alone does not pin a field set. The schemas are per snapshot for that reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  With ajv
&lt;/h2&gt;

&lt;p&gt;Nothing special required. Point ajv at it and go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Ajv&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ajv/dist/2020.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://rtblint.org/schemas/openrtb-2.6-202606-bid-request.schema.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ajv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Ajv&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;strict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;allErrors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;validate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ajv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;imp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;banner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;300&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instancePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;false
/at must be integer
/imp/0/banner/w must be integer
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of those are the classic OpenRTB failure: a numeric field arriving as a string. It is the single most common type error in real bid traffic, it is invisible to a lenient parser, and any partner enforcing the type drops you from the auction without telling you why.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;strict: false&lt;/code&gt;. The schemas use constructs ajv's strict mode complains about, and the complaints are about the schema's style rather than its correctness.&lt;/p&gt;

&lt;p&gt;Vendor the file rather than fetching it at runtime if this is on a hot path. It is 43 KB and it does not change unless you change versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  In CI without writing any JavaScript
&lt;/h2&gt;

&lt;p&gt;If all you want is a check on fixture files, &lt;code&gt;check-jsonschema&lt;/code&gt; is a single step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pipx install check-jsonschema&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;check-jsonschema \&lt;/span&gt;
      &lt;span class="s"&gt;--schemafile https://rtblint.org/schemas/openrtb-2.6-202606-bid-request.schema.json \&lt;/span&gt;
      &lt;span class="s"&gt;fixtures/requests/*.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That catches drift in your own test fixtures, which is worth more than it sounds. Fixtures rot: someone hand-edits one to reproduce a bug, gets the type wrong, and the fixture now encodes an invalid request that your tests happily assert against forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a schema cannot do
&lt;/h2&gt;

&lt;p&gt;Being straight about the ceiling here, because pointing a schema at OpenRTB and declaring the problem solved is how you end up with the partial-schema situation again.&lt;/p&gt;

&lt;p&gt;A schema checks one document's structure. It does not check:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-field rules.&lt;/strong&gt; &lt;code&gt;site&lt;/code&gt;, &lt;code&gt;app&lt;/code&gt; and &lt;code&gt;dooh&lt;/code&gt; are mutually exclusive. A schema can express that with &lt;code&gt;oneOf&lt;/code&gt;, awkwardly, but most of these rules are conditional in ways that get unreadable fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-document rules.&lt;/strong&gt; Whether a bid response's &lt;code&gt;impid&lt;/code&gt; matches an imp in the request that produced it. The other document is not in scope, so it is unreachable in principle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enum membership across 500-plus documented values.&lt;/strong&gt; Technically expressible, genuinely unpleasant to maintain by hand, and this is where AdCOM lists and vendor ranges live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deprecation and version drift.&lt;/strong&gt; A field can be structurally perfect and removed three snapshots ago. Schemas say valid or invalid, not "valid but you should stop."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;String contents.&lt;/strong&gt; &lt;code&gt;bid.adm&lt;/code&gt; is typed &lt;code&gt;string&lt;/code&gt;, so a schema is finished with it. Whether the string contains the markup type that &lt;code&gt;bid.mtype&lt;/code&gt; declares, or whether it got JSON-encoded twice on the way through your stack, is beyond the type system.&lt;/p&gt;

&lt;p&gt;Those are the checks &lt;a href="https://rtblint.org" rel="noopener noreferrer"&gt;rtblint&lt;/a&gt; exists for, and it is the same catalog underneath, so the schema and the linter agree about what a version contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;cargo &lt;span class="nb"&gt;install &lt;/span&gt;rtblint
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;rtblint validate &lt;span class="nt"&gt;--version&lt;/span&gt; 2.6-202606 request.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://rtblint.org/docs/rules/" rel="noopener noreferrer"&gt;rules reference&lt;/a&gt; lists every check with its id and severity, and there is a &lt;a href="https://rtblint.org/tester/" rel="noopener noreferrer"&gt;browser tester&lt;/a&gt; if you would rather paste one payload than install anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Take the schemas
&lt;/h2&gt;

&lt;p&gt;They are static files under &lt;a href="https://rtblint.org/docs/json-schemas/" rel="noopener noreferrer"&gt;rtblint.org/schemas/&lt;/a&gt;, no key and no rate limit. Use them in whatever validator you already have. If you find a field the spec has and the schema does not, that is a bug worth reporting, since both come out of the same extraction and a gap in one is usually a gap in both.&lt;/p&gt;

</description>
      <category>json</category>
      <category>node</category>
      <category>testing</category>
      <category>api</category>
    </item>
    <item>
      <title>OpenRTB 2.6 Spelled a Device Field 'didshal' for Six Months. Which Snapshot Are You Validating Against?</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Sun, 09 Aug 2026 08:17:27 +0000</pubDate>
      <link>https://dev.to/aleksuix/openrtb-26-spelled-a-device-field-didshal-for-six-months-which-snapshot-are-you-validating-2g31</link>
      <guid>https://dev.to/aleksuix/openrtb-26-spelled-a-device-field-didshal-for-six-months-which-snapshot-are-you-validating-2g31</guid>
      <description>&lt;p&gt;Most people treat "OpenRTB 2.6" as a version number. It is closer to a release train. Since 2022 the IAB has published dated snapshots of 2.6, each one adding fields, and occasionally fixing the previous one.&lt;/p&gt;

&lt;p&gt;Here is what that looks like as field-level drift across the snapshots, diffed from the generated schemas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2.6-202210 -&amp;gt; 2.6-202211:  +10 -0
   added:   dooh, dt, gpp, gpp_sid, inventorypartnerdomain, multiplier,
            qty, sourcetype, venuetype, venuetypetax
2.6-202303 -&amp;gt; 2.6-202309:  +13 -3
   added:   acat, count, didsha1, dpidsha1, durfloors, guar, maxdur,
            mindur, minint, plcmt, refresh, refsettings, reftype
   removed: didshal, dpidshal, kwarry
2.6-202309 -&amp;gt; 2.6-202402:  +1 -0
   added:   poddedupe
2.6-202409 -&amp;gt; 2.6-202501:  +3 -0
   added:   inserter, matcher, mm
2.6-202501 -&amp;gt; 2.6-202505:  +3 -0
   added:   cids, genres, gtax
2.6-202505 -&amp;gt; 2.6-202606:  +2 -0
   added:   firstbroadcast, realtime
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That November 2022 row is a real feature release: &lt;code&gt;dooh&lt;/code&gt; brought digital out-of-home into 2.6, and &lt;code&gt;gpp&lt;/code&gt; / &lt;code&gt;gpp_sid&lt;/code&gt; brought the Global Privacy Platform. Fine, expected, that is what version numbers are for.&lt;/p&gt;

&lt;p&gt;Now look at the removals in the second row. &lt;code&gt;didshal&lt;/code&gt;, &lt;code&gt;dpidshal&lt;/code&gt;, &lt;code&gt;kwarry&lt;/code&gt;. Those are not deprecated features. They are typos in the specification that were live for two snapshots and then corrected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The typos
&lt;/h2&gt;

&lt;p&gt;From the March 2023 snapshot of the OpenRTB 2.6 spec, in the Device object table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;td&amp;gt;&amp;lt;code&amp;gt;&lt;/span&gt;didshal&lt;span class="nt"&gt;&amp;lt;/code&amp;gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;string; DEPRECATED&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;Hardware device ID (e.g., IMEI); hashed via SHA1.&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The description says SHA1. The field name ends in a lowercase L. It should be &lt;code&gt;didsha1&lt;/code&gt;, and the same slip hit &lt;code&gt;dpidshal&lt;/code&gt;. Two fields, one wrong character each, in the canonical table that implementers read to build their bidders.&lt;/p&gt;

&lt;p&gt;The other one is subtler because the correct spelling was in the same document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- in the Site object --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;td&amp;gt;&amp;lt;code&amp;gt;&lt;/span&gt;kwarray&lt;span class="nt"&gt;&amp;lt;/code&amp;gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;Array of keywords about the site.&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- in the Content object, same document --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;td&amp;gt;&amp;lt;code&amp;gt;&lt;/span&gt;kwarry&lt;span class="nt"&gt;&amp;lt;/code&amp;gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;Array of keywords about the site.&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kwarray&lt;/code&gt; under Site and App, &lt;code&gt;kwarry&lt;/code&gt; under Content, with the Content entry also carrying the copy-pasted "about the site" description. Both spellings live in the March 2023 snapshot at once.&lt;/p&gt;

&lt;p&gt;All three were fixed in the September 2023 snapshot. The current spec has &lt;code&gt;didsha1&lt;/code&gt; and &lt;code&gt;kwarray&lt;/code&gt; and no trace of the others:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://raw.githubusercontent.com/InteractiveAdvertisingBureau/openrtb2.x/main/2.6.md &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;  | grep -c 'didshal\|kwarry'
0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this is not just trivia
&lt;/h2&gt;

&lt;p&gt;Consider what a validator built against the March 2023 snapshot does when a real bidder sends the field everyone actually implemented:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"device"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"didsha1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5f4dcc3b5aa765d61d8327deb882cf99"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It rejects it as an unknown field, because in that snapshot the field is called &lt;code&gt;didshal&lt;/code&gt;. And it accepts &lt;code&gt;didshal&lt;/code&gt;, which nothing in production sends.&lt;/p&gt;

&lt;p&gt;That is not a thought experiment. Same payload, two catalogs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;rtblint validate &lt;span class="nt"&gt;--version&lt;/span&gt; 2.6-202303 dev.json
&lt;span class="go"&gt;FAILED (OpenRTB 2.6-202303 bid request): 1 error(s), 0 warning(s).
- [error] device.didsha1: Device.didsha1 is not defined in the canonical
  OpenRTB 2.6-202303 catalog. (openrtb.field.undefined) · spec 3.2.18

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;rtblint validate &lt;span class="nt"&gt;--version&lt;/span&gt; 2.6-202606 dev.json
&lt;span class="go"&gt;OK with warnings (OpenRTB 2.6-202606 bid request): 1 warning(s).
- [warning] device.didsha1: device.didsha1 is deprecated in OpenRTB 2.6-202606.
  (openrtb.field.deprecated)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hard error on the left, a correct deprecation notice on the right, for a field name that has been the real one all along.&lt;/p&gt;

&lt;p&gt;The failure is inverted. Your validator is strictest about the thing it has wrong. Anyone shipping code from that snapshot got a validator that punishes correct behaviour, and because unknown-field handling is usually a warning rather than a hard failure, the signal is easy to tune out.&lt;/p&gt;

&lt;p&gt;This generalises past the typos. Every one of those &lt;code&gt;+N&lt;/code&gt; rows is a field that a bidder might send you and that an older catalog will call unknown. &lt;code&gt;poddedupe&lt;/code&gt; arrived in February 2024. &lt;code&gt;mm&lt;/code&gt; and &lt;code&gt;inserter&lt;/code&gt; in January 2025. &lt;code&gt;firstbroadcast&lt;/code&gt; and &lt;code&gt;realtime&lt;/code&gt; in June 2026. If you validate a 2026 bid request against a 2023 catalog, you get a pile of unknown-field noise that is entirely your own tooling's fault, and the natural reaction is to disable unknown-field checking altogether, which is exactly the check you wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pin the snapshot deliberately
&lt;/h2&gt;

&lt;p&gt;The fix is not "always use the newest." It is to make the version an explicit, visible choice rather than whatever your library happened to vendor.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://rtblint.org" rel="noopener noreferrer"&gt;rtblint&lt;/a&gt; is an OpenRTB linter I maintain, and it carries a separate extracted catalog per snapshot rather than one merged superset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;rtblint validate &lt;span class="nt"&gt;--version&lt;/span&gt; 2.6-202303 request.json   &lt;span class="c"&gt;# what your partner implemented&lt;/span&gt;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;rtblint validate &lt;span class="nt"&gt;--version&lt;/span&gt; 2.6-202606 request.json   &lt;span class="c"&gt;# what the spec says today&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running both is the useful move. The diff between the two reports is your compatibility surface with that partner, expressed as a list rather than a guess.&lt;/p&gt;

&lt;p&gt;It tracks 2.0, 2.1, 2.2, 2.3, 2.3.1, 2.4, 2.5, nine dated 2.6 snapshots, and 3.0. One snapshot, 2.6-202204, says so out loud rather than silently passing payloads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;rtblint validate &lt;span class="nt"&gt;--version&lt;/span&gt; 2.6-202204 dev.json
&lt;span class="gp"&gt;FAILED: OpenRTB 2.6-202204 has no canonical BidRequest catalog in this build;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="go"&gt;bid request validation is not supported for this version.
(openrtb.version.unsupported)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a deliberate choice. A validator that falls back to a neighbouring version when it does not have the right catalog is worse than one that refuses, because the first kind produces confident wrong answers and the second kind produces a bug report.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to check your own stack
&lt;/h2&gt;

&lt;p&gt;Two questions worth answering today, both cheap:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which snapshot is your validator using?&lt;/strong&gt; If the answer is "2.6" with no date, that is the finding. Something in your dependency tree picked a date for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it accept &lt;code&gt;didsha1&lt;/code&gt;?&lt;/strong&gt; A one-line test against a request containing that field tells you whether you are on a pre-September-2023 catalog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"id":"1","imp":[{"id":"1","banner":{"w":300,"h":250}}],
&lt;/span&gt;&lt;span class="go"&gt;  "site":{"id":"s"},"device":{"didsha1":"abc"}}' \
  | rtblint validate --stdin
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated &lt;a href="https://rtblint.org/docs/json-schemas/" rel="noopener noreferrer"&gt;JSON Schemas&lt;/a&gt; are published per snapshot if you would rather diff them yourself, and the &lt;a href="https://rtblint.org/docs/openrtb-versions/" rel="noopener noreferrer"&gt;version reference&lt;/a&gt; lists what changed at each step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader habit
&lt;/h2&gt;

&lt;p&gt;Dated snapshots inside a version number are an unusual pattern, and tooling ecosystems handle them badly because most versioning assumes a single ordered sequence of releases. OpenRTB 2.6 has nine of them and counting, they are not semver, and nothing in a &lt;code&gt;package.json&lt;/code&gt; will tell you which one your validator believes in.&lt;/p&gt;

&lt;p&gt;Treat the snapshot date as part of the version. Write it down in the integration doc next to the endpoint URL. When a partner says "we support 2.6," the useful follow-up is "as of when."&lt;/p&gt;

</description>
      <category>json</category>
      <category>api</category>
      <category>adtech</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Your Bid Response Is Valid JSON, Passes Its Schema, and Is Still Wrong</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Sun, 09 Aug 2026 08:16:16 +0000</pubDate>
      <link>https://dev.to/aleksuix/your-bid-response-is-valid-json-passes-its-schema-and-is-still-wrong-2jha</link>
      <guid>https://dev.to/aleksuix/your-bid-response-is-valid-json-passes-its-schema-and-is-still-wrong-2jha</guid>
      <description>&lt;p&gt;Here is a bid response. It is well-formed JSON, every field has the right type, every required field is present, and it validates against the OpenRTB 2.6 bid response schema without complaint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"req-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cur"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"seatbid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"seat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dsp-9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"b1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"impid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"99"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"mtype"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"adm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;VAST version=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;4.2&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;&amp;lt;Ad/&amp;gt;&amp;lt;/VAST&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dealid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deal-does-not-exist"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it through a validator on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;rtblint validate &lt;span class="nt"&gt;--type&lt;/span&gt; response resp.json
&lt;span class="go"&gt;OK (OpenRTB 2.6-202606 bid response): no issues found.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now run the same file with the bid request that produced it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;rtblint validate &lt;span class="nt"&gt;--type&lt;/span&gt; response &lt;span class="nt"&gt;--request&lt;/span&gt; req.json resp.json
&lt;span class="go"&gt;FAILED (OpenRTB 2.6-202606 bid response): 2 error(s), 0 warning(s).
- [error] cur: Response currency "EUR" is not among the currencies the request
  allows (USD). (openrtb.response.cur_not_allowed) · spec 4.2.1
- [error] seatbid[0].bid[0].impid: impid "99" does not match the id of any Imp
  in the bid request. (openrtb.bid.impid_unknown) · spec 4.2.3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same bytes. Same schema. Two errors that will get this bid discarded by every exchange that checks, and no-bid by the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the first run cannot find them
&lt;/h2&gt;

&lt;p&gt;This is not a gap in the validator. It is a property of the data.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;"impid": "99"&lt;/code&gt; is a string, which is what the spec requires. Nothing about the value &lt;code&gt;"99"&lt;/code&gt; is malformed. Whether it is &lt;em&gt;correct&lt;/em&gt; depends on the set of &lt;code&gt;imp[].id&lt;/code&gt; values in a document the validator was not given. The same is true of &lt;code&gt;cur&lt;/code&gt;: &lt;code&gt;"EUR"&lt;/code&gt; is a valid ISO 4217 code, and whether it is allowed depends on the request's &lt;code&gt;cur&lt;/code&gt; array.&lt;/p&gt;

&lt;p&gt;A schema describes one document. These are relational facts spanning two. No amount of JSON Schema will express them, because the other side of the relation is not in scope.&lt;/p&gt;

&lt;p&gt;That distinction is worth naming precisely, because it decides where you put your effort. Structural validity is a property of a document. Coherence is a property of an exchange. Most OpenRTB tooling only checks the first one, which is why most OpenRTB bugs are in the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cross-document checks that matter
&lt;/h2&gt;

&lt;p&gt;Given the request alongside the response, a validator can check things that are otherwise invisible:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;impid&lt;/code&gt; resolves to a real imp.&lt;/strong&gt; The single most common one. An off-by-one in imp indexing, a stale cached response, or a bidder that reuses ids across auctions all produce this, and all of them look fine in isolation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;cur&lt;/code&gt; is in the request's allowed list.&lt;/strong&gt; The request advertises the currencies the exchange will settle in. Bidding outside that list is not a rounding problem, it is a discarded bid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;mtype&lt;/code&gt; matches what the imp offered.&lt;/strong&gt; If the imp only carried a &lt;code&gt;video&lt;/code&gt; object, a bid with &lt;code&gt;mtype: 1&lt;/code&gt; (banner) is bidding on inventory that does not exist in that slot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dealid&lt;/code&gt; corresponds to a deal on that imp.&lt;/strong&gt; A private deal id that the imp never listed will not clear at the deal price, and depending on the exchange may not clear at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;seat&lt;/code&gt; is one the request permits&lt;/strong&gt;, where the request constrains seats.&lt;/p&gt;

&lt;p&gt;The middle two produce output like this, from a bid that declares banner markup against a video-only imp and cites a deal the imp never listed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;- [error] seatbid[0].bid[0].mtype: mtype 1 declares banner markup, but imp "1"
  does not offer a banner subtype. (openrtb.bid.mtype_not_offered) · spec 4.2.3
- [warning] seatbid[0].bid[0].dealid: dealid "nope" references a deal, but imp
&lt;/span&gt;&lt;span class="gp"&gt;  "1" carries no pmp object at all;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;verify the deal was arranged out of band.
&lt;span class="go"&gt;  (openrtb.bid.dealid_unknown) · spec 4.2.3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the severities. An &lt;code&gt;mtype&lt;/code&gt; the imp never offered is an error, because no reading of the request makes that bid servable. An unknown &lt;code&gt;dealid&lt;/code&gt; is a warning, because deals genuinely do get arranged out of band and a validator that called that an error would be wrong often enough to get switched off.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a single document can still tell you
&lt;/h2&gt;

&lt;p&gt;Cross-validation is not the only thing missing from a plain schema check. Some incoherence lives entirely inside the response and is still beyond structural validation, because it requires interpreting a string field's contents.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;adm&lt;/code&gt; is the obvious case. It is typed as a string, so a schema is done thinking about it the moment it confirms the type. But &lt;code&gt;mtype&lt;/code&gt; declares what kind of markup that string is supposed to contain, and the two can disagree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;rtblint validate &lt;span class="nt"&gt;--type&lt;/span&gt; response resp3.json
&lt;span class="go"&gt;FAILED (OpenRTB 2.6-202606 bid response): 2 error(s), 0 warning(s).
- [error] seatbid[0].bid[0].adm: mtype 4 declares native markup, but adm does not
&lt;/span&gt;&lt;span class="gp"&gt;  parse as a JSON object;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;a native response must be the JSON Native Markup
&lt;span class="go"&gt;  Response. (openrtb.bid.adm.native_not_json) · spec 4.2.3
- [error] seatbid[0].bid[1].adm: mtype 2 declares video markup (VAST XML), but
  adm is a JSON payload. (openrtb.bid.adm.markup_type_mismatch) · spec 4.2.3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the one that costs the most debugging time per occurrence, double encoding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;- [error] seatbid[0].bid[2].adm: adm parses to another JSON string rather than
&lt;/span&gt;&lt;span class="gp"&gt;  markup;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;it looks like the creative payload was JSON-encoded twice.
&lt;span class="go"&gt;  (openrtb.bid.adm.double_encoded) · spec 4.2.3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Double encoding happens when a service serialises the creative, then a downstream service serialises the already-serialised string again. The result is a valid JSON string containing a valid JSON string, so every type check passes, and the player receives &lt;code&gt;"{\"native\":...}"&lt;/code&gt; where it expected an object. It renders as nothing. There is no error anywhere in the chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it in
&lt;/h2&gt;

&lt;p&gt;The reason to do this in CI rather than in a postmortem is that both documents are already sitting in your integration tests. You have a request fixture and an expected response fixture, and right now you are probably asserting on a handful of fields by hand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;cargo &lt;span class="nb"&gt;install &lt;/span&gt;rtblint
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;one response against its request
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;rtblint validate &lt;span class="nt"&gt;--type&lt;/span&gt; response &lt;span class="nt"&gt;--request&lt;/span&gt; req.json resp.json
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;many responses against one request, one JSON payload per stdin line
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;responses.jsonl | rtblint validate &lt;span class="nt"&gt;--batch&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; response &lt;span class="nt"&gt;--request&lt;/span&gt; req.json
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;machine-readable, &lt;span class="k"&gt;for &lt;/span&gt;a CI annotation step
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;rtblint validate &lt;span class="nt"&gt;--type&lt;/span&gt; response &lt;span class="nt"&gt;--request&lt;/span&gt; req.json &lt;span class="nt"&gt;--format&lt;/span&gt; json resp.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit codes are 0 for valid, 1 for validation errors, 2 for usage or I/O problems, so it drops into a pipeline without a wrapper script.&lt;/p&gt;

&lt;p&gt;If you would rather look at one payload in a browser first, the &lt;a href="https://rtblint.org/tester/" rel="noopener noreferrer"&gt;OpenRTB tester&lt;/a&gt; takes a request and a response and shows the findings with JSON paths. The &lt;a href="https://rtblint.org/docs/cli/" rel="noopener noreferrer"&gt;CLI docs&lt;/a&gt; cover the batch and version-pinning flags, and &lt;a href="https://rtblint.org/docs/what-rtblint-checks/" rel="noopener noreferrer"&gt;what rtblint checks&lt;/a&gt; is the full list if you want to know what you are getting before installing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;Schema validation answers "is this a well-formed OpenRTB document." That is a real question and worth answering automatically. It is just a narrower question than "will this bid win, and will the creative render," and the gap between the two is where the revenue is.&lt;/p&gt;

&lt;p&gt;If you are only validating responses in isolation, you are checking the half of the problem that rarely breaks.&lt;/p&gt;

</description>
      <category>json</category>
      <category>api</category>
      <category>testing</category>
      <category>adtech</category>
    </item>
    <item>
      <title>VPAID Is Deprecated. Here Is How to Find Out How Much You Are Still Serving.</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Sun, 09 Aug 2026 08:11:02 +0000</pubDate>
      <link>https://dev.to/aleksuix/vpaid-is-deprecated-here-is-how-to-find-out-how-much-you-are-still-serving-33hf</link>
      <guid>https://dev.to/aleksuix/vpaid-is-deprecated-here-is-how-to-find-out-how-much-you-are-still-serving-33hf</guid>
      <description>&lt;p&gt;VPAID has been deprecated since VAST 4.1. There will not be a VPAID 3.0. It cannot execute in server-side ad insertion, it cannot execute on connected TV, and the IAB has published two separate replacement standards for the two things it used to do.&lt;/p&gt;

&lt;p&gt;None of that stops it from being in your tags. Deprecation is a statement about a specification. It has no effect on the XML your ad server emitted this morning.&lt;/p&gt;

&lt;p&gt;So the useful question is not "should we move off VPAID," which was settled years ago. It is "how much of it are we still serving, and where is it costing us fill." That is answerable in an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it stopped working, briefly
&lt;/h2&gt;

&lt;p&gt;Four independent reasons, and they compound:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It runs in your page's JavaScript context.&lt;/strong&gt; A VPAID unit has full access to the publisher's DOM, cookies and globals. That is not a sandbox with holes, it is the absence of a sandbox, and it was routinely used for exactly what you would expect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSAI has no JavaScript runtime.&lt;/strong&gt; Server-side ad insertion stitches the ad into the media stream at the server. There is no browser there. VPAID cannot run in principle, not as an implementation gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CTV has no browser-compatible JavaScript runtime either.&lt;/strong&gt; Smart TVs, sticks and set-top boxes use native media players. VPAID is not slow on CTV, it is non-executable. And CTV is where premium video budget went.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It costs latency on every impression that does work.&lt;/strong&gt; Because the VPAID script controls media loading, the player cannot pre-cache the video. Every impression waits for the unit to initialise before anything can buffer.&lt;/p&gt;

&lt;p&gt;The replacements split the job. &lt;a href="https://vastlint.org/docs/simid/" rel="noopener noreferrer"&gt;SIMID&lt;/a&gt; handles interactivity in a sandboxed iframe talking over &lt;code&gt;postMessage&lt;/code&gt;, so the player keeps control of the media. OMID handles verification and viewability. Neither one needs access to your page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four signatures
&lt;/h2&gt;

&lt;p&gt;VPAID does not announce itself in one place, which is why audits miss it. There are four distinct patterns, and a tag can match some without the others.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The explicit declaration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;MediaFile&lt;/span&gt; &lt;span class="na"&gt;delivery=&lt;/span&gt;&lt;span class="s"&gt;"progressive"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"application/javascript"&lt;/span&gt;
           &lt;span class="na"&gt;apiFramework=&lt;/span&gt;&lt;span class="s"&gt;"VPAID"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"640"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"360"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;![CDATA[https://cdn.example.com/vpaid-unit.js]]&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/MediaFile&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;apiFramework="VPAID"&lt;/code&gt; is the honest case. Easy to grep, easy to count.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The JavaScript media file with no apiFramework
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;MediaFile&lt;/span&gt; &lt;span class="na"&gt;delivery=&lt;/span&gt;&lt;span class="s"&gt;"progressive"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"application/javascript"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;![CDATA[https://cdn.example.com/unit.js]]&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/MediaFile&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;&amp;lt;MediaFile&amp;gt;&lt;/code&gt; whose type is &lt;code&gt;application/javascript&lt;/code&gt; or &lt;code&gt;application/x-javascript&lt;/code&gt; is a script, not a video, whatever it declares. Some ad servers omit &lt;code&gt;apiFramework&lt;/code&gt; here, and a search that only looks for the string "VPAID" will miss every one of them.&lt;/p&gt;

&lt;p&gt;Same for &lt;code&gt;application/x-shockwave-flash&lt;/code&gt;, which you will still find in long-tail inventory and which has been unplayable in every browser since 2021.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. VPAID sitting next to its own replacement
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;MediaFiles&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;MediaFile&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"application/javascript"&lt;/span&gt; &lt;span class="na"&gt;apiFramework=&lt;/span&gt;&lt;span class="s"&gt;"VPAID"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/MediaFile&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;MediaFile&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"video/mp4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/MediaFile&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;InteractiveCreativeFile&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text/html"&lt;/span&gt; &lt;span class="na"&gt;apiFramework=&lt;/span&gt;&lt;span class="s"&gt;"SIMID"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/InteractiveCreativeFile&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/MediaFiles&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the most common shape in tags that have been half-migrated. Both the old and new interactive paths are present. On a player that supports SIMID it works fine. On one that prefers VPAID you are back to the original problem, and on CTV the VPAID entry is dead weight that some stitchers reject the whole tag over.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. AdParameters with no interactive file
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;AdParameters&amp;gt;&lt;/code&gt; exists to pass configuration into an interactive unit. A tag with &lt;code&gt;&amp;lt;AdParameters&amp;gt;&lt;/code&gt; and no &lt;code&gt;&amp;lt;InteractiveCreativeFile&amp;gt;&lt;/code&gt; is almost always feeding a VPAID creative, even if nothing else in the document says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting it
&lt;/h2&gt;

&lt;p&gt;Grep gets you the first pass over a corpus of saved tags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rlE&lt;/span&gt; &lt;span class="s1"&gt;'apiFramework="VPAID"|application/(x-)?javascript|x-shockwave-flash'&lt;/span&gt; tags/ | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That undercounts signature 4 and overcounts anything that mentions those strings in a tracking URL, so for a real number use a validator that understands the document structure. Running the half-migrated example above through &lt;a href="https://vastlint.org" rel="noopener noreferrer"&gt;vastlint&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vastlint check vpaid.xml
&lt;span class="go"&gt;vpaid.xml  VAST 4.2
  warning  apiFramework="VPAID" is deprecated as of VAST 4.1, use SIMID or OMID instead
           VAST-4.1-vpaid-apiframework
           /VAST/Ad[0]/InLine/Creatives/Creative[0]/Linear/MediaFiles/MediaFile[0]:20:15
&lt;/span&gt;&lt;span class="gp"&gt;  warning  &amp;lt;MediaFile apiFramework="VPAID"&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;alongside &amp;lt;InteractiveCreativeFile&amp;gt;, VPAID is not
&lt;span class="go"&gt;           supported in CTV and should be removed when SIMID/OMID is present
           VAST-4.1-vpaid-in-interactive-context
           /VAST/Ad[0]/InLine/Creatives/Creative[0]/Linear/MediaFiles/MediaFile[0]:20:15

✓ 0 errors, 2 warnings, 1 info
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that both are warnings, not errors. A VPAID tag is still perfectly valid VAST. The schema has no opinion about deprecation, which is exactly why this survives in inventory: nothing in the validation path fails, so nothing forces the cleanup.&lt;/p&gt;

&lt;p&gt;Over a directory, with JSON out so you can count by rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vastlint check &lt;span class="nt"&gt;--format&lt;/span&gt; json tags/&lt;span class="k"&gt;*&lt;/span&gt;.xml &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;  | jq -r '.issues[].id' | sort | uniq -c | sort -rn
   2 VAST-4.1-vpaid-in-interactive-context
   2 VAST-4.1-vpaid-apiframework
   2 VAST-4.1-mezzanine-recommended
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(JSON mode emits one object per file, so &lt;code&gt;jq&lt;/code&gt; reads it as a stream rather than an array.)&lt;/p&gt;

&lt;p&gt;Install with &lt;code&gt;cargo install vastlint&lt;/code&gt;, &lt;code&gt;brew install aleksUIX/tap/vastlint&lt;/code&gt;, or a pre-built binary from the releases page. There is a WASM build on npm as &lt;code&gt;vastlint&lt;/code&gt; if you would rather call it from Node than shell out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each signature costs
&lt;/h2&gt;

&lt;p&gt;Not all of these are equally urgent, and treating them as one bucket is how audits stall.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signature&lt;/th&gt;
&lt;th&gt;CTV&lt;/th&gt;
&lt;th&gt;SSAI&lt;/th&gt;
&lt;th&gt;Desktop web&lt;/th&gt;
&lt;th&gt;Priority&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VPAID with no video fallback&lt;/td&gt;
&lt;td&gt;No fill&lt;/td&gt;
&lt;td&gt;No fill&lt;/td&gt;
&lt;td&gt;Plays&lt;/td&gt;
&lt;td&gt;Fix now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPAID alongside an MP4&lt;/td&gt;
&lt;td&gt;Falls back&lt;/td&gt;
&lt;td&gt;Falls back&lt;/td&gt;
&lt;td&gt;Plays VPAID&lt;/td&gt;
&lt;td&gt;Fix soon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPAID next to SIMID&lt;/td&gt;
&lt;td&gt;Depends on player&lt;/td&gt;
&lt;td&gt;Falls back&lt;/td&gt;
&lt;td&gt;Ambiguous&lt;/td&gt;
&lt;td&gt;Remove the VPAID entry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flash media file&lt;/td&gt;
&lt;td&gt;No fill&lt;/td&gt;
&lt;td&gt;No fill&lt;/td&gt;
&lt;td&gt;No fill&lt;/td&gt;
&lt;td&gt;Delete&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first row is the one that is actually costing money right now. A creative whose only rendition is a VPAID script gets zero fill on the majority of premium video inventory, and because it is a no-bid rather than an error, it shows up in your reporting as absence rather than failure.&lt;/p&gt;

&lt;p&gt;The third row is the cheapest fix in the table: the SIMID path already exists, so removing the VPAID &lt;code&gt;&amp;lt;MediaFile&amp;gt;&lt;/code&gt; is a deletion, not a migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing the migration
&lt;/h2&gt;

&lt;p&gt;The full path is &lt;a href="https://vastlint.org/guides/vast-vpaid-migration/" rel="noopener noreferrer"&gt;documented here&lt;/a&gt;, and the &lt;a href="https://vastlint.org/docs/vpaid/" rel="noopener noreferrer"&gt;VPAID reference&lt;/a&gt; has the version history and the compatibility matrix by VAST version. Compressed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Interactivity moves to &lt;code&gt;&amp;lt;InteractiveCreativeFile apiFramework="SIMID"&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Verification and viewability move to &lt;code&gt;&amp;lt;AdVerifications&amp;gt;&lt;/code&gt; with OMID.&lt;/li&gt;
&lt;li&gt;Anything you were tracking from inside the VPAID unit moves to &lt;code&gt;&amp;lt;TrackingEvents&amp;gt;&lt;/code&gt;, which the player fires and which works in SSAI.&lt;/li&gt;
&lt;li&gt;Every creative gets a real video &lt;code&gt;&amp;lt;MediaFile&amp;gt;&lt;/code&gt;, so there is always something to play.&lt;/li&gt;
&lt;li&gt;Declare the VAST version you are actually emitting.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is the one to do first if you do nothing else. A native rendition alongside the VPAID unit turns a zero-fill creative into one that at least serves as video everywhere, and it does not require touching the interactive build at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest summary
&lt;/h2&gt;

&lt;p&gt;VPAID is not going to stop working on a particular date. It stopped working in the environments that matter, gradually, over several years, and it will keep half-working on desktop web indefinitely. That is worse than a hard cutoff, because there is never a day when the problem becomes visible.&lt;/p&gt;

&lt;p&gt;The signal to watch is not an error rate. It is fill on CTV and SSAI inventory for creatives that carry a script where a video should be. Count those first.&lt;/p&gt;

</description>
      <category>adtech</category>
      <category>ctv</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Playing VAST Ads in Video.js Without the Google IMA SDK</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Sun, 09 Aug 2026 08:10:00 +0000</pubDate>
      <link>https://dev.to/aleksuix/playing-vast-ads-in-videojs-without-the-google-ima-sdk-2i5e</link>
      <guid>https://dev.to/aleksuix/playing-vast-ads-in-videojs-without-the-google-ima-sdk-2i5e</guid>
      <description>&lt;p&gt;The usual way to put ads in Video.js is &lt;code&gt;videojs-ima&lt;/code&gt;, which wraps Google's IMA SDK. It works, it is well maintained, and for most people it is the right answer.&lt;/p&gt;

&lt;p&gt;It is also a third-party runtime you do not control, loaded from &lt;code&gt;imasdk.googleapis.com&lt;/code&gt;, that brings its own VAST parser, its own wrapper-following policy, its own error handling and its own opinions about VPAID. There are reasons to want out: bundle and network budget, environments where the SDK is blocked or unavailable, a need to control wrapper depth and timeouts yourself, or simply not wanting a Google dependency in the playback path.&lt;/p&gt;

&lt;p&gt;This is what the do-it-yourself path involves. I am not going to pretend it is less work, because it is more. But the work is bounded and the pieces are legible, which is not always true of the alternative.&lt;/p&gt;

&lt;h2&gt;
  
  
  contrib-ads is a state machine, not an ad player
&lt;/h2&gt;

&lt;p&gt;The first thing to get straight, and the project says it plainly in its own README:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;videojs-contrib-ads&lt;/code&gt; is not a stand-alone ad plugin. It is a library that is used by other ad plugins in order to fully support video.js.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And from the integration docs, on &lt;code&gt;startLinearAdMode&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;During this time, your ad plugin plays ads. videojs-contrib-ads does not handle actual ad playback.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So contrib-ads handles the hard, boring, easy-to-get-wrong parts of being an ad plugin in Video.js: pausing content, showing the spinner, restoring player state after the break, timing out if ads take too long, and redispatching media events so the rest of your app does not see ad playback as content playback. That last one alone justifies using it.&lt;/p&gt;

&lt;p&gt;What it does not do is fetch VAST, parse VAST, choose a rendition, or fire a tracking pixel. That is your half.&lt;/p&gt;

&lt;h2&gt;
  
  
  The control flow
&lt;/h2&gt;

&lt;p&gt;Initialise contrib-ads in the same tick as the player. This is a real constraint, not a style preference. The plugin relies on &lt;code&gt;loadstart&lt;/code&gt; and will emit an error if it missed one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;videojs&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;videojs-contrib-ads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;videojs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content_video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;controls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/content.mp4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video/mp4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ads&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// same tick as videojs(), not in a ready callback&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handshake is: you tell contrib-ads when your ad logic is initialised, it tells you when a break is due.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// You fetch and parse, then announce readiness.&lt;/span&gt;
&lt;span class="nf"&gt;loadVast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AD_TAG_URL&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;currentAd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;adsready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// contrib-ads fires this once both `play` and `adsready` have happened.&lt;/span&gt;
&lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;readyforpreroll&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;currentAd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startLinearAdMode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;src&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currentAd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaFile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currentAd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaFile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;adplaying&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ads-ad-started&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// clears the loading spinner&lt;/span&gt;
    &lt;span class="nf"&gt;fire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentAd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;impressions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;adended&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endLinearAdMode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// content resumes&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;readyforpostroll&lt;/code&gt; gives you the postroll slot. Midrolls you drive yourself off &lt;code&gt;timeupdate&lt;/code&gt;, calling &lt;code&gt;startLinearAdMode&lt;/code&gt; and &lt;code&gt;endLinearAdMode&lt;/code&gt; around each break.&lt;/p&gt;

&lt;p&gt;That is the whole integration surface. Everything else is VAST.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parsing the tag
&lt;/h2&gt;

&lt;p&gt;VAST is XML, so &lt;code&gt;DOMParser&lt;/code&gt; gets you most of the way. The parts you need for linear playback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadVast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wrapper limit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;xml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DOMParser&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;parseFromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/xml&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// A wrapper points at another tag. Follow it, and keep its trackers.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Wrapper &amp;gt; VASTAdTagURI&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadVast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;collectTrackers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;linear&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;InLine Creative Linear&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;impressions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;InLine &amp;gt; Impression&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="na"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseDuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Duration&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;mediaFile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pickMediaFile&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MediaFile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)]),&lt;/span&gt;
    &lt;span class="na"&gt;trackers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;collectTrackers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things about that which are easy to get wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrapper trackers accumulate, they do not get replaced.&lt;/strong&gt; Every hop in the chain contributes its own &lt;code&gt;&amp;lt;Impression&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;Error&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;TrackingEvents&amp;gt;&lt;/code&gt;. If you follow a wrapper and keep only the inline document's pixels, you have silently dropped the SSP's and the DSP's tracking, which is the kind of bug that surfaces as a discrepancy meeting three weeks later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need a depth limit.&lt;/strong&gt; Wrapper chains can loop. The IMA SDK defaults to 4 redirects. Pick a number and enforce it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;&amp;lt;Duration&amp;gt;&lt;/code&gt; is &lt;code&gt;HH:MM:SS&lt;/code&gt; or &lt;code&gt;HH:MM:SS.mmm&lt;/code&gt;, not seconds.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parseDuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Everything is CDATA-wrapped and whitespace-padded.&lt;/strong&gt; Always &lt;code&gt;.trim()&lt;/code&gt;. A URL with a leading newline fails silently as an image request and you will not see it in the network tab without looking for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking a media file
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;MediaFile&amp;gt;&lt;/code&gt; is repeated, and choosing badly is the most common cause of "the ad does not play on this device."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pickMediaFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;playable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;bitrate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bitrate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;width&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;delivery&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;canPlayType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="c1"&gt;// Closest rendition at or below the player's width, then highest bitrate.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currentWidth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;playable&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bitrate&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bitrate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;playable&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filter on &lt;code&gt;canPlayType&lt;/code&gt; before anything else. A tag will happily offer you &lt;code&gt;video/x-flv&lt;/code&gt; and &lt;code&gt;application/javascript&lt;/code&gt; alongside the MP4. That second one is a VPAID creative, which you do not want here and which is deprecated anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Firing trackers at the right time
&lt;/h2&gt;

&lt;p&gt;This is the part that separates a working integration from one that plays ads but reports nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;QUARTILES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;firstQuartile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;midpoint&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;thirdQuartile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;complete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;attachTracking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fired&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;fired&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trackers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;beacon&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;adplaying&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;creativeView&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timeupdate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;QUARTILES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;beacon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;fired&lt;/code&gt; set is not optional. &lt;code&gt;timeupdate&lt;/code&gt; fires several times a second, and a quartile pixel that fires forty times is worse than one that never fires, because the first looks like fraud and the second looks like a bug.&lt;/p&gt;

&lt;p&gt;Two more:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use the ad's declared &lt;code&gt;&amp;lt;Duration&amp;gt;&lt;/code&gt;, not the media element's.&lt;/strong&gt; They disagree more often than you would expect, and when they do, the declared duration is what the buyer paid against.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not derive quartiles from &lt;code&gt;player.duration()&lt;/code&gt; during an ad.&lt;/strong&gt; Depending on how the source switch went, that may still be the content duration, which puts every quartile in the wrong place with no error anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reporting errors back
&lt;/h2&gt;

&lt;p&gt;VAST has an error reporting mechanism that almost nobody implements on the do-it-yourself path, and it is the one that makes your tag debuggable for the ad server that sent it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reportError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;tpl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;beacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tpl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[ERRORCODE]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// no playable rendition after filtering&lt;/span&gt;
&lt;span class="nf"&gt;reportError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// media file failed to load&lt;/span&gt;
&lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aderror&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reportError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;405&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;[ERRORCODE]&lt;/code&gt; is a macro the player substitutes. If you send the URL without substituting it, the ad server logs a literal &lt;code&gt;[ERRORCODE]&lt;/code&gt; and learns nothing. The codes are a defined registry, and the wording is specific enough to be worth matching:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;403&lt;/strong&gt; "Couldn't find MediaFile that is supported by this media player, based on the attributes of the MediaFile element." Your &lt;code&gt;canPlayType&lt;/code&gt; filter emptied the list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;405&lt;/strong&gt; "Problem displaying MediaFile. Media player found a MediaFile with supported type but couldn't display it." You picked one and it failed anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;301&lt;/strong&gt; "Timeout of VAST URI provided in Wrapper element." Use this for wrapper hops that time out, not for wrapper documents that parse badly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What you give up
&lt;/h2&gt;

&lt;p&gt;Being straight about the tradeoff:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VPAID.&lt;/strong&gt; Which is fine. It is deprecated as of VAST 4.1, blocked in most CTV environments, and not worth building an isolation boundary for in 2026.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VMAP ad rules.&lt;/strong&gt; IMA can take a VMAP document and schedule the whole break structure for you. On this path you parse VMAP yourself or hardcode your break times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measurement integrations.&lt;/strong&gt; OMID viewability, in particular, expects a certified integration. If you have OM SDK obligations, the DIY path is not a shortcut.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Someone else's compatibility matrix.&lt;/strong&gt; IMA has been run against more devices than your code will be. That is a real asset and the main argument for staying with it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Validate the tag before you debug the player
&lt;/h2&gt;

&lt;p&gt;The failure mode I would flag hardest: when an ad does not play, the instinct is to instrument the player. Half the time the tag is malformed and no player would have played it. That is an especially easy trap here, because you wrote the parser, so you assume the parser is the suspect.&lt;/p&gt;

&lt;p&gt;Check the tag first. I maintain &lt;a href="https://vastlint.org" rel="noopener noreferrer"&gt;vastlint&lt;/a&gt;, an open source VAST validator, largely because of this pattern. The &lt;a href="https://vastlint.org/tester/" rel="noopener noreferrer"&gt;tag tester&lt;/a&gt; takes a live ad tag URL, follows the wrapper chain, and shows you the resolved XML with the errors marked, which tells you in a few seconds whether you are debugging your code or someone else's tag.&lt;/p&gt;

&lt;p&gt;If you would rather stay in your terminal, the CLI takes a URL directly and follows the wrapper chain for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;cargo &lt;span class="nb"&gt;install &lt;/span&gt;vastlint
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vastlint check &lt;span class="s2"&gt;"https://your-ad-server/vast?..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://vastlint.org/guides/vast-videojs/" rel="noopener noreferrer"&gt;Video.js VAST guide&lt;/a&gt; covers the videojs-ima route in detail if you decide the DIY path is not worth it, including the plugin options that matter for wrapper depth and timeouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth it?
&lt;/h2&gt;

&lt;p&gt;If you need VPAID, full VMAP scheduling, or certified viewability, use the IMA SDK. If you need a small, inspectable ad path with no third-party runtime, the code above is close to the whole shape of it. Roughly 200 lines for prerolls with tracking, and every one of them is yours to step through, which on the day something goes wrong is the entire point.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>video</category>
      <category>adtech</category>
    </item>
    <item>
      <title>There Is No VAST 4.3 Schema, and the 4.4 Draft Accepts a Completely Empty Ad</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Sun, 09 Aug 2026 08:08:48 +0000</pubDate>
      <link>https://dev.to/aleksuix/there-is-no-vast-43-schema-and-the-44-draft-accepts-a-completely-empty-ad-47ha</link>
      <guid>https://dev.to/aleksuix/there-is-no-vast-43-schema-and-the-44-draft-accepts-a-completely-empty-ad-47ha</guid>
      <description>&lt;p&gt;If you validate VAST tags, you probably assume there is a schema behind the version number. For 4.3 there is not, and for the 4.4 draft there is one that accepts an ad containing nothing at all.&lt;/p&gt;

&lt;p&gt;Both are checkable in about thirty seconds. Neither is a hypothetical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4.3 schema is one byte
&lt;/h2&gt;

&lt;p&gt;IAB Tech Lab publishes VAST in a public repo, with schemas under &lt;code&gt;schemas/&lt;/code&gt;. Ask the API what is in the 4.3 file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.github.com/repos/InteractiveAdvertisingBureau/vast/contents/schemas/vast_4.3.xsd &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;  | jq '.size'
1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One byte. Pull it down and it is a single newline. The file was created, committed, and never filled in.&lt;/p&gt;

&lt;p&gt;So when a tool says it validated your tag "against VAST 4.3," it did not mean against a 4.3 XSD, because none exists. In practice tools do one of three things: fall back to the 4.2 schema, check structure they encoded by hand from the specification PDF, or check nothing version-specific at all. All three are defensible. None of them is what most people picture.&lt;/p&gt;

&lt;p&gt;This is worth knowing mostly because 4.3 is the current released version. The version people are told to target is the one with no machine-readable definition.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4.4 draft validates an empty wrapper
&lt;/h2&gt;

&lt;p&gt;4.4 is a draft, and drafts are allowed to be rough. This one has a specific defect that is worth understanding because it is a good lesson in how XSD actually behaves.&lt;/p&gt;

&lt;p&gt;Take the smallest wrapper ad you can write, one with no children whatsoever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;VAST&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"http://www.iab.com/VAST"&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"4.4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Ad&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"empty"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;Wrapper/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/Ad&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/VAST&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Against the 4.4 draft schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;xmllint &lt;span class="nt"&gt;--noout&lt;/span&gt; &lt;span class="nt"&gt;--schema&lt;/span&gt; vast_4.4.xsd ew.xml
&lt;span class="go"&gt;ew.xml validates
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same document, retargeted at 4.2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;xmllint &lt;span class="nt"&gt;--noout&lt;/span&gt; &lt;span class="nt"&gt;--schema&lt;/span&gt; vast_4.2.xsd ew42.xml
&lt;span class="go"&gt;ew42.xml:4: element Wrapper: Schemas validity error : Element
'{http://www.iab.com/VAST}Wrapper': Missing child element(s).
Expected is ( {http://www.iab.com/VAST}AdSystem ).
ew42.xml fails to validate
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A wrapper with no &lt;code&gt;&amp;lt;AdSystem&amp;gt;&lt;/code&gt;, no &lt;code&gt;&amp;lt;VASTAdTagURI&amp;gt;&lt;/code&gt;, no &lt;code&gt;&amp;lt;Impression&amp;gt;&lt;/code&gt;. It has no ad server to attribute, nowhere to go next, and nothing to track. Under the draft schema it is a valid ad.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;p&gt;Here is the content model in the draft:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;xs:complexType&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"vastWrapper_type"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xs:choice&lt;/span&gt; &lt;span class="na"&gt;minOccurs=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt; &lt;span class="na"&gt;maxOccurs=&lt;/span&gt;&lt;span class="s"&gt;"unbounded"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;xs:element&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"AdSystem"&lt;/span&gt;     &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"vastAdSystem_type"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;xs:element&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"VASTAdTagURI"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"vastURIElement_type"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;xs:element&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Impression"&lt;/span&gt;   &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"vastImpression_type"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;xs:choice minOccurs="0" maxOccurs="unbounded"&lt;/code&gt; says: zero or more of any of these, in any order, any number of times. The per-element &lt;code&gt;minOccurs&lt;/code&gt; and &lt;code&gt;maxOccurs&lt;/code&gt; that would normally express "exactly one AdSystem, at least one Impression" are not there, and adding them would not help much, because a repeating choice group reasons about the group, not the branch.&lt;/p&gt;

&lt;p&gt;So the model has thrown away three separate things at once: which children are required, how many of each are allowed, and what order they come in. &lt;code&gt;&amp;lt;Wrapper/&amp;gt;&lt;/code&gt; satisfies it by taking the choice zero times. So does a wrapper with fourteen &lt;code&gt;&amp;lt;VASTAdTagURI&amp;gt;&lt;/code&gt; elements.&lt;/p&gt;

&lt;p&gt;The 4.2 schema does not have this problem because it uses &lt;code&gt;xs:sequence&lt;/code&gt; with explicit occurrence constraints on each element, which is the ordinary way to say "one of these, then one or more of those."&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that looks right and is not
&lt;/h2&gt;

&lt;p&gt;The instinct when you see an order-independent content model is to reach for &lt;code&gt;xs:all&lt;/code&gt;. It exists precisely to say "all of these, in any order."&lt;/p&gt;

&lt;p&gt;It does not work here, and it is worth knowing why before you suggest it to anyone.&lt;/p&gt;

&lt;p&gt;In XSD 1.0, every particle inside &lt;code&gt;xs:all&lt;/code&gt; is capped at &lt;code&gt;maxOccurs="1"&lt;/code&gt;. That is not a stylistic limit, it is in the language. VAST needs &lt;code&gt;&amp;lt;Impression&amp;gt;&lt;/code&gt; to repeat, since a wrapper routinely carries several impression pixels from different parties. Model it with &lt;code&gt;xs:all&lt;/code&gt; and the schema will reject the second &lt;code&gt;&amp;lt;Impression&amp;gt;&lt;/code&gt;, turning a cardinality bug that is too loose into one that is too strict.&lt;/p&gt;

&lt;p&gt;I suggested &lt;code&gt;xs:all&lt;/code&gt; in the issue I filed, then tested it against libxml2 and had to retract it in a follow-up comment. The correct fix is the boring one: &lt;code&gt;xs:sequence&lt;/code&gt; with real &lt;code&gt;minOccurs&lt;/code&gt; and &lt;code&gt;maxOccurs&lt;/code&gt; per element, which is what 4.2 already does and what the draft moved away from.&lt;/p&gt;

&lt;p&gt;I filed this as &lt;a href="https://github.com/InteractiveAdvertisingBureau/vast/issues/58" rel="noopener noreferrer"&gt;issue #58&lt;/a&gt; and opened &lt;a href="https://github.com/InteractiveAdvertisingBureau/vast/pull/59" rel="noopener noreferrer"&gt;PR #59&lt;/a&gt; restoring required elements and ordering. Both are open at the time of writing. The patch is +22/-22 and keeps every IAB sample tag passing, which I checked by running the full sample set before and after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The larger point about schema validation
&lt;/h2&gt;

&lt;p&gt;Even a correct XSD would only get you part of the way, and this is the thing worth internalising if you are building tag QA.&lt;/p&gt;

&lt;p&gt;A schema can express structure: which elements exist, how they nest, how many times, what type the text is. It cannot express most of what the VAST specification actually requires, because those requirements are conditional, cross-referential, or written in prose. A few examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;apiFramework="VPAID"&lt;/code&gt; is deprecated as of VAST 4.1. Schema-legal, still a problem.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sequence&lt;/code&gt; on &lt;code&gt;&amp;lt;Ad&amp;gt;&lt;/code&gt; is typed &lt;code&gt;xs:integer&lt;/code&gt;, so &lt;code&gt;sequence="0"&lt;/code&gt; and &lt;code&gt;sequence="-1"&lt;/code&gt; validate. Nothing in the schema requires sequence values to be unique across a pod either, so two ads can claim the same slot.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;skipoffset&lt;/code&gt; larger than the creative's &lt;code&gt;&amp;lt;Duration&amp;gt;&lt;/code&gt; is two valid fields that contradict each other.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;&amp;lt;Icon&amp;gt;&lt;/code&gt; positioned outside the player dimensions is arithmetic, not structure.&lt;/li&gt;
&lt;li&gt;Wrapper chain depth limits are a runtime property of a document you have not fetched yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is a schema's job. It means the schema check is a floor, not a ceiling, and "it validates" answers a narrower question than most people are asking when they say it.&lt;/p&gt;

&lt;p&gt;This is why I ended up writing a linter rather than shipping an XSD. &lt;a href="https://vastlint.org" rel="noopener noreferrer"&gt;vastlint&lt;/a&gt; is open source, written in Rust, and its rules come from the specification prose as much as from the schemas, with each rule tracing back to the version and source it came from. The tradeoff is honest: rules extracted from prose require judgment and can be wrong, whereas a schema check cannot be wrong about the thing it checks. It just checks less than you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to reproduce any of this
&lt;/h2&gt;

&lt;p&gt;Everything above is one command each.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;the 4.3 placeholder
&lt;span class="go"&gt;curl -s https://api.github.com/repos/InteractiveAdvertisingBureau/vast/contents/schemas/vast_4.3.xsd | jq .size

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;the empty wrapper, against the draft schema
&lt;span class="go"&gt;xmllint --noout --schema vast_4.4.xsd ew.xml
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the prose-level rules, the &lt;a href="https://vastlint.org/guides/iab-vast-validator/" rel="noopener noreferrer"&gt;IAB VAST validator guide&lt;/a&gt; sets out what spec compliance covers and where platform-specific behaviour takes over, and the &lt;a href="https://vastlint.org/tester/" rel="noopener noreferrer"&gt;tag tester&lt;/a&gt; will fetch a live tag and follow its wrapper chain so you can see the resolved document rather than the one you started with.&lt;/p&gt;

&lt;p&gt;To be clear about affiliation: vastlint is independent and not an IAB Tech Lab product. The issue and the PR above are ordinary contributions to a public repo, filed because the bug was easier to fix than to work around.&lt;/p&gt;

</description>
      <category>xml</category>
      <category>adtech</category>
      <category>opensource</category>
      <category>testing</category>
    </item>
    <item>
      <title>Your Ad Pod Lost Half Its Ads and the VAST Still Validates</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Sun, 09 Aug 2026 08:08:45 +0000</pubDate>
      <link>https://dev.to/aleksuix/your-ad-pod-lost-half-its-ads-and-the-vast-still-validates-2pdn</link>
      <guid>https://dev.to/aleksuix/your-ad-pod-lost-half-its-ads-and-the-vast-still-validates-2pdn</guid>
      <description>&lt;p&gt;A publisher tells you the pre-roll break is 90 seconds and takes four ads. Your ad server builds a pod of four. Reporting shows two impressions. You pull the VAST response, run it through a validator, and it comes back clean.&lt;/p&gt;

&lt;p&gt;Nothing is broken in the sense a validator understands. The document is well-formed, schema-valid, and complete. It just describes two ads instead of four, and there is nothing in the XML that says it was ever supposed to describe four.&lt;/p&gt;

&lt;h2&gt;
  
  
  VAST has no pod container
&lt;/h2&gt;

&lt;p&gt;This is the part that surprises people who have only read about ad pods rather than parsed them.&lt;/p&gt;

&lt;p&gt;An ad pod in VAST is not an element. There is no &lt;code&gt;&amp;lt;AdPod&amp;gt;&lt;/code&gt;, no &lt;code&gt;maxAds&lt;/code&gt;, no &lt;code&gt;maxDuration&lt;/code&gt;. A pod is a convention: several sibling &lt;code&gt;&amp;lt;Ad&amp;gt;&lt;/code&gt; elements under &lt;code&gt;&amp;lt;VAST&amp;gt;&lt;/code&gt;, each carrying a &lt;code&gt;sequence&lt;/code&gt; attribute.&lt;/p&gt;

&lt;p&gt;You can check this yourself against the schemas IAB Tech Lab publishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'AdPod\|maxAds\|maxDuration'&lt;/span&gt; vast_2.0.1.xsd vast_4.0.xsd &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    vast_4.1.xsd vast_4.2.xsd vast_4.4.xsd
vast_2.0.1.xsd:0
vast_4.0.xsd:0
vast_4.1.xsd:0
vast_4.2.xsd:0
vast_4.4.xsd:0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero, from 2.0 through the 4.4 draft. (4.3 is absent from that list because IAB Tech Lab never published a 4.3 XSD. The file in the repo is a one-byte placeholder, which is its own interesting problem and not this article's.)&lt;/p&gt;

&lt;p&gt;The only pod-related construct in the schema is one optional attribute on &lt;code&gt;&amp;lt;Ad&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;xs:attribute&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"sequence"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"xs:integer"&lt;/span&gt; &lt;span class="na"&gt;use=&lt;/span&gt;&lt;span class="s"&gt;"optional"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xs:annotation&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;xs:documentation&amp;gt;&lt;/span&gt;
      Identifies the sequence of multiple Ads that are part of an Ad Pod.
    &lt;span class="nt"&gt;&amp;lt;/xs:documentation&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/xs:annotation&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/xs:attribute&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole feature. An integer, optional, with no relationship to any other ad in the document expressed anywhere.&lt;/p&gt;

&lt;p&gt;I mention this because a fair amount of writing on ad pods, including a page on my own site until I checked it this week, describes an &lt;code&gt;&amp;lt;AdPod&amp;gt;&lt;/code&gt; element with &lt;code&gt;maxAds&lt;/code&gt; and &lt;code&gt;maxDuration&lt;/code&gt; children. That element does not exist in any released VAST version. The names are real, but they come from the request side, and mixing them up sends people looking for a validation error that no validator can produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pod size lives in the request, not the response
&lt;/h2&gt;

&lt;p&gt;If you want to know how many ads a break should hold, you have to look at what was asked for, not what came back.&lt;/p&gt;

&lt;p&gt;In OpenRTB 2.6 that is on the impression object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"imp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"video"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"podid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"midroll-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"poddur"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"maxseq"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"podseq"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"mincpmpersec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;poddur&lt;/code&gt; is the total seconds available. &lt;code&gt;maxseq&lt;/code&gt; is the maximum number of ads. In VMAP it is the &lt;code&gt;&amp;lt;AdBreak&amp;gt;&lt;/code&gt; that owns the slot, with &lt;code&gt;allowMultipleAds&lt;/code&gt; sitting on its &lt;code&gt;&amp;lt;AdSource&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The VAST response then fills the slot and carries none of that context forward. So when you hold a VAST document in your hand, you are holding something that cannot tell you whether it is complete. A two-ad pod and a truncated four-ad pod are byte-for-byte the same kind of object.&lt;/p&gt;

&lt;p&gt;That asymmetry is the whole problem. Validation answers "is this document legal." It cannot answer "is this document all of what was sent," because the document does not carry its own expected size.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four ways ads disappear
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. A wrapper collapses the pod
&lt;/h3&gt;

&lt;p&gt;The most common one. Your pod passes through an SSP wrapper, and that wrapper sets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Wrapper&lt;/span&gt; &lt;span class="na"&gt;allowMultipleAds=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt; &lt;span class="na"&gt;followAdditionalWrappers=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The player keeps the first ad and discards the rest. One impression where you expected four.&lt;/p&gt;

&lt;p&gt;The subtler version is a wrapper that omits the attribute entirely. Here is the declaration in the 4.2 XSD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;xs:attribute&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"allowMultipleAds"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"xs:boolean"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xs:annotation&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;xs:documentation&amp;gt;&lt;/span&gt;
      a Boolean value that identifies whether multiple ads are allowed
      in the requested VAST response.
    &lt;span class="nt"&gt;&amp;lt;/xs:documentation&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/xs:annotation&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/xs:attribute&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what is not there: no &lt;code&gt;default&lt;/code&gt;. The schema does not say what an absent &lt;code&gt;allowMultipleAds&lt;/code&gt; means. Some players read the absence as permission, some as refusal, and both readings are schema-valid. You will see the same tag pod correctly on one device and collapse on another, with no error on either.&lt;/p&gt;

&lt;p&gt;Set it explicitly on every wrapper you control. Absence is not a default, it is a coin flip.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Sequence collisions and gaps
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;sequence&lt;/code&gt; is typed &lt;code&gt;xs:integer&lt;/code&gt;, not &lt;code&gt;xs:positiveInteger&lt;/code&gt;. That means all of these are schema-valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Ad&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"a"&lt;/span&gt; &lt;span class="na"&gt;sequence=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;Ad&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"b"&lt;/span&gt; &lt;span class="na"&gt;sequence=&lt;/span&gt;&lt;span class="s"&gt;"-1"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;Ad&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"c"&lt;/span&gt; &lt;span class="na"&gt;sequence=&lt;/span&gt;&lt;span class="s"&gt;"2"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;Ad&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"d"&lt;/span&gt; &lt;span class="na"&gt;sequence=&lt;/span&gt;&lt;span class="s"&gt;"2"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two ads with &lt;code&gt;sequence="2"&lt;/code&gt; give the player no defined order between them. Most implementations fall back to document order, some drop one, and a few reorder unpredictably across sessions. Negative and zero values are accepted by the schema and handled inconsistently in the wild.&lt;/p&gt;

&lt;p&gt;Mixing sequenced and unsequenced ads in one response is its own trap. An &lt;code&gt;&amp;lt;Ad&amp;gt;&lt;/code&gt; with no &lt;code&gt;sequence&lt;/code&gt; is a standalone ad, not pod member zero. A response containing both is asking the player to decide whether it received one pod or a pod plus a loose ad, and implementations split on the answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Duration overflow
&lt;/h3&gt;

&lt;p&gt;The publisher owns the break length. If your four ads sum to 100 seconds and the break is 90, something has to give, and the player decides what. Usually it plays until the break is exhausted and drops the remainder, which means your last ad in sequence order is the one that silently never runs.&lt;/p&gt;

&lt;p&gt;Nothing in the VAST document flags this, because the document has no idea what the break length is. The only place the mismatch is visible is by comparing &lt;code&gt;poddur&lt;/code&gt; from the request against the sum of &lt;code&gt;&amp;lt;Duration&amp;gt;&lt;/code&gt; values in the response, and that requires holding both at once.&lt;/p&gt;

&lt;p&gt;Worth knowing: the VAST error code registry has a code for exactly this outcome. Code 206, introduced in VAST 4.1, reads "Ad Break shortened. Ad was not served." If your player emits 206, it is telling you the break ran out before your ad did. It is one of the more useful codes in the registry and one of the least commonly implemented, so treat its absence as no evidence either way.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. One ad fails and takes the rest with it
&lt;/h3&gt;

&lt;p&gt;A broken media file or an unreachable wrapper endpoint in the middle of a pod should cost you one ad. In several player implementations it costs you the remainder of the pod, because the error handling unwinds the whole break rather than skipping to the next sequence.&lt;/p&gt;

&lt;p&gt;This is not spec-mandated behaviour, it is an implementation choice, and it is the reason pod position matters commercially. An ad in sequence 1 that fails is much more expensive than the same failure in sequence 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can actually check before it ships
&lt;/h2&gt;

&lt;p&gt;Statically, from the response alone, you can catch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Duplicate &lt;code&gt;sequence&lt;/code&gt; values across &lt;code&gt;&amp;lt;Ad&amp;gt;&lt;/code&gt; elements&lt;/li&gt;
&lt;li&gt;Mixed sequenced and unsequenced ads in one response&lt;/li&gt;
&lt;li&gt;Zero and negative sequence values&lt;/li&gt;
&lt;li&gt;Wrappers in the chain that set &lt;code&gt;allowMultipleAds="false"&lt;/code&gt; or omit it&lt;/li&gt;
&lt;li&gt;Per-ad validity, so that no single ad in the pod is the one that unwinds the break&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mixed-sequence case is the one I see most in real tags, and it has a rule ID: &lt;a href="https://vastlint.org/docs/rules/VAST-2.0-ad-sequence/" rel="noopener noreferrer"&gt;VAST-2.0-ad-sequence&lt;/a&gt;. The others fall out of ordinary per-ad validation, which matters more in a pod than anywhere else precisely because of failure mode 4.&lt;/p&gt;

&lt;p&gt;What you cannot check statically is whether the pod is complete. That comparison needs the request. If you are debugging a short break, get &lt;code&gt;poddur&lt;/code&gt; and &lt;code&gt;maxseq&lt;/code&gt; from the bid request and put them next to the response before you start reading XML. Half the pod investigations I have watched went thirty minutes deep into a document that was never going to contain the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting hands on it
&lt;/h2&gt;

&lt;p&gt;I maintain &lt;a href="https://vastlint.org" rel="noopener noreferrer"&gt;vastlint&lt;/a&gt;, an open source VAST validator written in Rust, which is where the rule IDs above come from. Two things on the site are useful for pods specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;a href="https://vastlint.org/tester/" rel="noopener noreferrer"&gt;VAST tag tester&lt;/a&gt; fetches a live tag URL, follows the wrapper chain, and shows you the resolved response. That is how you find the wrapper that set &lt;code&gt;allowMultipleAds="false"&lt;/code&gt;, since it will not be in the tag you started with.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://vastlint.org/guides/vast-ad-pods/" rel="noopener noreferrer"&gt;ad pods guide&lt;/a&gt; has the full sequenced-pod example and the checklist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is also a CLI if you would rather keep this in CI than in a browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;cargo &lt;span class="nb"&gt;install &lt;/span&gt;vastlint
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vastlint check pod.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;A VAST response describes what was returned. It does not describe what was requested, and a pod is defined entirely by what was requested. Any tool that only reads the response is structurally unable to tell you an ad is missing, which is why "the tag validates" and "the break is short" are statements that can both be true at once.&lt;/p&gt;

&lt;p&gt;Check the response for the things it can prove, and go to the request for the thing it cannot.&lt;/p&gt;

</description>
      <category>ctv</category>
      <category>adtech</category>
      <category>xml</category>
      <category>debugging</category>
    </item>
    <item>
      <title>An Empty VAST Wrapper Is Schema-Valid in 4.4. It Was Not in 2.0.</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Sun, 09 Aug 2026 06:30:53 +0000</pubDate>
      <link>https://dev.to/aleksuix/an-empty-vast-wrapper-is-schema-valid-in-44-it-was-not-in-20-5bob</link>
      <guid>https://dev.to/aleksuix/an-empty-vast-wrapper-is-schema-valid-in-44-it-was-not-in-20-5bob</guid>
      <description>&lt;p&gt;A VAST wrapper with no AdSystem, no VASTAdTagURI and no Impression validates against the VAST 4.4 draft schema. The same document has been invalid in every version from 2.0 through 4.2. It is one line of XSD, and it is almost certainly a side effect of the CTV Ad Portfolio restructure rather than a decision anyone made on purpose.&lt;/p&gt;

&lt;p&gt;I have filed it with IAB Tech Lab. This post is the working, because the reproduction is short enough that anyone can check it in about a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  The change
&lt;/h2&gt;

&lt;p&gt;In vast_4.4.xsd on master, both vastInLine_type and vastWrapper_type wrap their children in a single compositor: an xs:choice with minOccurs zero and maxOccurs unbounded.&lt;/p&gt;

&lt;p&gt;That looks harmless. It is the idiom people reach for when they want to say "these children may appear in any order". What it actually says is stronger than that. In XSD, the cardinality on the compositor governs the content model, and the minOccurs on the individual child elements only describes a single selection from the choice. Set the choice itself to zero-or-more and every constraint underneath it stops binding.&lt;/p&gt;

&lt;p&gt;So the children still declare minOccurs="1". They are still, in effect, optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compositor in question
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- vast_4.4.xsd, vastWrapper_type and vastInLine_type --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;xs:choice&lt;/span&gt; &lt;span class="na"&gt;minOccurs=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt; &lt;span class="na"&gt;maxOccurs=&lt;/span&gt;&lt;span class="s"&gt;"unbounded"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xs:element&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"AdSystem"&lt;/span&gt;     &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"vastAdSystem_type"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xs:element&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"VASTAdTagURI"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"vastURIElement_type"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xs:element&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Impression"&lt;/span&gt;   &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"vastImpression_type"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xs:element&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Creatives"&lt;/span&gt;    &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"vastCreatives_type"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- ... --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/xs:choice&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three consequences, not one
&lt;/h2&gt;

&lt;p&gt;The empty wrapper is the headline, but the compositor gives up three separate guarantees at once. Each is reproducible with xmllint against the published schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  What now validates in 4.4
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Everything is optional. An empty &lt;code&gt;&amp;lt;Wrapper/&amp;gt;&lt;/code&gt; validates. So does an empty &lt;code&gt;&amp;lt;InLine/&amp;gt;&lt;/code&gt;, with no AdSystem, no AdTitle, no Impression and no Creatives.&lt;/li&gt;
&lt;li&gt;Everything repeats. maxOccurs="unbounded" on the choice means any branch can be selected repeatedly, so three &lt;code&gt;&amp;lt;AdSystem&amp;gt;&lt;/code&gt; elements in one InLine validate, even though AdSystem has always been exactly one.&lt;/li&gt;
&lt;li&gt;Order stops mattering. 4.2 used xs:sequence, so the order was fixed. A choice imposes none, so &lt;code&gt;&amp;lt;Impression&amp;gt;&lt;/code&gt; before &lt;code&gt;&amp;lt;AdSystem&amp;gt;&lt;/code&gt; validates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduction: all three validate against the published 4.4 schema
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- 1. empty wrapper --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;VAST&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"4.4"&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"http://www.iab.com/VAST"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Ad&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"a"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;Wrapper/&amp;gt;&amp;lt;/Ad&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/VAST&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- 2. empty inline --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;VAST&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"4.4"&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"http://www.iab.com/VAST"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Ad&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"a"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;InLine/&amp;gt;&amp;lt;/Ad&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/VAST&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- 3. AdSystem three times --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;VAST&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"4.4"&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"http://www.iab.com/VAST"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Ad&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"a"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;InLine&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;AdSystem&amp;gt;&lt;/span&gt;A&lt;span class="nt"&gt;&amp;lt;/AdSystem&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;AdSystem&amp;gt;&lt;/span&gt;B&lt;span class="nt"&gt;&amp;lt;/AdSystem&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;AdSystem&amp;gt;&lt;/span&gt;C&lt;span class="nt"&gt;&amp;lt;/AdSystem&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/InLine&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/Ad&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/VAST&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Check it yourself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sO&lt;/span&gt; https://raw.githubusercontent.com/InteractiveAdvertisingBureau/vast/master/vast_4.4.xsd
xmllint &lt;span class="nt"&gt;--noout&lt;/span&gt; &lt;span class="nt"&gt;--schema&lt;/span&gt; vast_4.4.xsd empty-wrapper.xml
&lt;span class="c"&gt;# empty-wrapper.xml validates&lt;/span&gt;

&lt;span class="c"&gt;# the same shape against 4.2&lt;/span&gt;
xmllint &lt;span class="nt"&gt;--noout&lt;/span&gt; &lt;span class="nt"&gt;--schema&lt;/span&gt; vast_4.2.xsd empty-wrapper-42.xml
&lt;span class="c"&gt;# Element 'Wrapper': Missing child element(s). Expected is ( AdSystem ).&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What 4.2 actually required
&lt;/h2&gt;

&lt;p&gt;The contrast is worth being precise about, because the requirement did not live where you might expect. In 4.2, Wrapper_type extends AdDefinitionBase_type, and it is the base type that carries the required fields: AdSystem exactly once, Impression once or more. Wrapper_type then adds VASTAdTagURI as required and Creatives as optional. Both use xs:sequence.&lt;/p&gt;

&lt;p&gt;Impression in particular has been required on wrappers since 2.0. That is a twenty-year-old constraint, and the wrapper is exactly where it matters most, because a wrapper that fires no impression is a measurement hole in the middle of a chain.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The schema went from expressing a content model to enumerating a vocabulary. Those are different jobs, and only one of them can gate anything.&lt;/p&gt;

&lt;p&gt;vastlint standards note&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why this matters if you gate on the XSD
&lt;/h2&gt;

&lt;p&gt;Plenty of teams treat the published XSD as an acceptance gate: ad server ingest checks, partner certification, QA rigs that reject anything the schema will not accept. For those, 4.4 currently passes wrapper responses that cannot deliver an ad and cannot record an impression.&lt;/p&gt;

&lt;p&gt;It also sharpens a question the working group was already asking in a separate thread about whether the XSD is meant to be usable as a validator at all. My answer has not changed: it is not, and it never fully was. The prose has always carried requirements the schema does not express, which is the reason a rule-based validator exists in the first place. But there is a difference between a schema that is incomplete and a schema that is more permissive than the version it supersedes.&lt;/p&gt;

&lt;p&gt;The practical takeaway for anyone shipping 4.4 today: do not loosen your own checks to match the draft. Keep requiring AdSystem, Impression and VASTAdTagURI on wrappers, because the prose still does and every prior version did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The likely fix
&lt;/h2&gt;

&lt;p&gt;If the intent was order-independence, xs:all expresses that while keeping per-element cardinality intact. XSD 1.0 limits xs:all particles to maxOccurs one, so repeating elements like Impression need handling, but the required ones stay required.&lt;/p&gt;

&lt;p&gt;If the intent really was a fully open content model, then the schema and the prose have diverged and the annotation should say so out loud, so that nobody builds a gate on top of it expecting otherwise.&lt;/p&gt;

&lt;p&gt;Either way it is a small patch. I have offered to write it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate a 4.4 tag against the prose, not just the schema
&lt;/h2&gt;

&lt;p&gt;vastlint checks VAST 2.0 through the 4.4 draft against rules derived from the specification text, not only the XSD, so a wrapper missing AdSystem or Impression still fails. Paste a tag and see. Nothing is stored.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://vastlint.org/validate/" rel="noopener noreferrer"&gt;Open the VAST validator&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources and further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/InteractiveAdvertisingBureau/vast/issues/58" rel="noopener noreferrer"&gt;IAB Tech Lab VAST issue #58&lt;/a&gt; &lt;em&gt;(GitHub)&lt;/em&gt; The filed report, with the full reproduction and the 4.2 comparison table.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/InteractiveAdvertisingBureau/vast/blob/master/vast_4.4.xsd" rel="noopener noreferrer"&gt;vast_4.4.xsd on master&lt;/a&gt; &lt;em&gt;(IAB Tech Lab)&lt;/em&gt; The published draft schema. See vastInLine_type and vastWrapper_type.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vastlint.org/docs/vast-4-4/" rel="noopener noreferrer"&gt;VAST 4.4: what actually shipped&lt;/a&gt; &lt;em&gt;(vastlint)&lt;/em&gt; The full technical breakdown of the 4.4 draft, content model by content model.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vastlint.org/docs/vast-4-3-to-4-4/" rel="noopener noreferrer"&gt;VAST 4.3 to 4.4 migration guide&lt;/a&gt; &lt;em&gt;(vastlint)&lt;/em&gt; Element-by-element changes with a migration checklist.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vastlint.org/guides/vast-wrapper-chains/" rel="noopener noreferrer"&gt;Wrapper chains and where they break&lt;/a&gt; &lt;em&gt;(vastlint)&lt;/em&gt; What a wrapper has to carry for a chain to resolve and measure correctly.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>vast44</category>
      <category>xsd</category>
      <category>wrapper</category>
      <category>validation</category>
    </item>
    <item>
      <title>Top 10 OpenRTB Mistakes That Are Costing You Money</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Tue, 28 Jul 2026 04:57:24 +0000</pubDate>
      <link>https://dev.to/aleksuix/top-10-openrtb-mistakes-that-are-costing-you-money-3fpd</link>
      <guid>https://dev.to/aleksuix/top-10-openrtb-mistakes-that-are-costing-you-money-3fpd</guid>
      <description>&lt;p&gt;OpenRTB has a property that makes it uniquely painful to debug: almost nothing in it fails loudly. The spec tells parsers to ignore unknown fields. The version travels in an optional HTTP header most parties drop. A malformed bid request does not bounce with a 400; it just gets no-bid by every partner that enforces the rule you broke, while lenient partners keep the traffic flowing and convince you everything is fine.&lt;/p&gt;

&lt;p&gt;That is the revenue mechanic behind every mistake on this list. Each broken field removes some set of bidders from the auction, and every removed bidder lowers your clearing price. You do not see an error. You see CPMs that are a little worse than they should be, forever.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://rtblint.org" rel="noopener noreferrer"&gt;RTBlint&lt;/a&gt;, an open source OpenRTB linter, and these are the ten failures I see most in real traffic. Each one maps to a stable rule ID, so you can catch all of them in CI instead of in a quarterly revenue review.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Strings where the spec says integers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tmax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"120"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of those values is wrong. &lt;code&gt;at&lt;/code&gt;, &lt;code&gt;tmax&lt;/code&gt;, &lt;code&gt;test&lt;/code&gt;, and the various flag fields are integers. JSON has real numbers, but request builders that template their way through string concatenation ship quoted digits, and a typed parser (Go structs, Java POJOs, anything protobuf-backed) rejects the entire request. Not the field. The request.&lt;/p&gt;

&lt;p&gt;This is the most expensive mistake on the list because it costs you 100% of the bidders that parse strictly, and those tend to be the larger, better-engineered ones.&lt;/p&gt;

&lt;p&gt;RTBlint flags every occurrence as &lt;code&gt;openrtb.type.mismatch&lt;/code&gt; with the exact JSON path.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. An impression that never says what is for sale
&lt;/h2&gt;

&lt;p&gt;Each object in &lt;code&gt;imp[]&lt;/code&gt; must carry at least one of &lt;code&gt;banner&lt;/code&gt;, &lt;code&gt;video&lt;/code&gt;, &lt;code&gt;audio&lt;/code&gt;, or &lt;code&gt;native&lt;/code&gt;. An imp with none of them is an auction for nothing: the bidder has no idea what creative form is acceptable, so it does not bid. This usually comes from a request builder that assembles imp objects conditionally and has a branch where every condition is false.&lt;/p&gt;

&lt;p&gt;Rule: &lt;code&gt;openrtb.imp.media_type.required&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Still sending video.placement instead of plcmt
&lt;/h2&gt;

&lt;p&gt;The 2.6-202303 update deprecated &lt;code&gt;imp.video.placement&lt;/code&gt; in favor of &lt;code&gt;imp.video.plcmt&lt;/code&gt;, with values from the AdCOM Plcmt Subtypes list: 1 instream, 2 accompanying content, 3 interstitial, 4 no content / standalone. The two enums do not map one to one, so this is a re-declaration, not a rename.&lt;/p&gt;

&lt;p&gt;Why it costs money: buyers now key their instream vs outstream classification off &lt;code&gt;plcmt&lt;/code&gt;, and instream commands a multiple of outstream pricing. If you sell instream inventory but only declare the old &lt;code&gt;placement&lt;/code&gt; field, buyers that follow the current spec classify you as undeclared and price you like outstream.&lt;/p&gt;

&lt;p&gt;Rule: &lt;code&gt;openrtb.field.deprecated&lt;/code&gt;, three years running the most common finding in video traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Privacy signals stuck at their 2.5 paths
&lt;/h2&gt;

&lt;p&gt;OpenRTB 2.6 promoted the GDPR signals into core objects:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;2.5 era&lt;/th&gt;
&lt;th&gt;2.6&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;regs.ext.gdpr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;regs.gdpr&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;user.ext.consent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;user.consent&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;source.ext.schain&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;source.schain&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Requests built on 2.5-era code keep writing the &lt;code&gt;ext&lt;/code&gt; paths, and 2.6-native consumers never look there. The failure mode for privacy fields is worse than a lost bid: a buyer that cannot find a GDPR signal on EU traffic has two options, drop the request or bid non-personalized. Both pay you less. Some legal teams mandate the first.&lt;/p&gt;

&lt;p&gt;Rule: &lt;code&gt;openrtb.field.moved&lt;/code&gt;, which also tells you the replacement path.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Half a GPP pair
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;regs.gpp&lt;/code&gt; carries the Global Privacy Platform consent string and &lt;code&gt;regs.gpp_sid&lt;/code&gt; says which GPP sections apply. They only mean something together. A &lt;code&gt;gpp&lt;/code&gt; string without &lt;code&gt;gpp_sid&lt;/code&gt; cannot be interpreted (which section governs this user?), and a &lt;code&gt;gpp_sid&lt;/code&gt; without the string is an empty claim.&lt;/p&gt;

&lt;p&gt;As US state privacy laws multiply, buyers increasingly treat an uninterpretable consent signal the same way they treat a missing one: suppress personalization or skip the request.&lt;/p&gt;

&lt;p&gt;Rules: &lt;code&gt;openrtb.regs.gpp_without_gpp_sid&lt;/code&gt; and &lt;code&gt;openrtb.regs.gpp_sid_without_gpp&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. site and app in the same request
&lt;/h2&gt;

&lt;p&gt;A bid request must contain at most one of &lt;code&gt;site&lt;/code&gt;, &lt;code&gt;app&lt;/code&gt;, or &lt;code&gt;dooh&lt;/code&gt;. They answer the same question about what kind of inventory this is, and a request carrying two of them is ambiguous, so many bidders drop it outright. The usual cause is a request builder that merges publisher defaults with placement data and keeps both branches.&lt;/p&gt;

&lt;p&gt;Rule: &lt;code&gt;openrtb.fields.mutually_exclusive&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Floors in a currency you did not declare
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;imp.bidfloor&lt;/code&gt; is a CPM, and &lt;code&gt;imp.bidfloorcur&lt;/code&gt; is its currency, defaulting to USD. It does not inherit from &lt;code&gt;cur&lt;/code&gt; or from your account settings or from anything else.&lt;/p&gt;

&lt;p&gt;A floor of 120 that you meant as Japanese yen but sent without &lt;code&gt;"bidfloorcur": "JPY"&lt;/code&gt; reads as a 120 USD floor. Every bid loses to it. Your fill for that placement goes to zero and nothing anywhere tells you why. The mirror image also happens: a floor meant in USD declared in a minor currency invites bids far below what you intended.&lt;/p&gt;

&lt;p&gt;Rules: &lt;code&gt;openrtb.imp.bidfloorcur_format_invalid&lt;/code&gt; for malformed currency codes, &lt;code&gt;openrtb.imp.bidfloor_negative&lt;/code&gt; for negative floors.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. A tmax nobody can meet
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tmax&lt;/code&gt; is the total time in milliseconds you allow for bids to arrive, network latency included. Two failure modes show up in traffic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tmax: 0&lt;/code&gt; or a negative value, which technically gives bidders no time at all. Rule: &lt;code&gt;openrtb.request.tmax_non_positive&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tmax: 300000&lt;/code&gt;, which is five minutes and means someone is sending seconds. Bidders either clamp it or distrust the request. Rule: &lt;code&gt;openrtb.request.tmax_implausible&lt;/code&gt;, which fires above 10,000 ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way you distort the auction: too low and slow-but-high-paying bidders never make it back, too high and you are the exchange whose requests look broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. A supply chain that fails audit
&lt;/h2&gt;

&lt;p&gt;Buyers cross-check &lt;code&gt;source.schain&lt;/code&gt; against sellers.json and ads.txt before spending. Three defects break that audit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The same &lt;code&gt;asi&lt;/code&gt;/&lt;code&gt;sid&lt;/code&gt; node appearing twice: &lt;code&gt;openrtb.schain.duplicate_node&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A node missing the required &lt;code&gt;hp&lt;/code&gt; flag: &lt;code&gt;openrtb.schain.node.hp_missing&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Empty &lt;code&gt;asi&lt;/code&gt; or &lt;code&gt;sid&lt;/code&gt; strings, which make the node unverifiable: &lt;code&gt;openrtb.schain.node.identifier_empty&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An unverifiable supply chain is indistinguishable from a suspicious one, and post-adselect, buyers do not give the benefit of the doubt. They route spend to paths they can verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Bid responses that contradict themselves
&lt;/h2&gt;

&lt;p&gt;Everything above is the request side. The response side has its own money leaks, and they hit DSPs and bidders instead of publishers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;adm&lt;/code&gt; present but &lt;code&gt;mtype&lt;/code&gt; missing, so the exchange cannot classify the creative: &lt;code&gt;openrtb.bid.mtype_missing&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;VAST XML or native JSON that was JSON-encoded twice, arriving as a string of escaped quotes no player can render: &lt;code&gt;openrtb.bid.adm.double_encoded&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A native bid whose &lt;code&gt;adm&lt;/code&gt; is not parseable JSON: &lt;code&gt;openrtb.bid.adm.native_not_json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;impid&lt;/code&gt; that does not match any imp in the request, which wins nothing ever: &lt;code&gt;openrtb.bid.impid_unknown&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Bidding in a currency the request did not allow: &lt;code&gt;openrtb.response.cur_not_allowed&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are the worst kind of expensive because you paid to get here: QPS, bid computation, sometimes even the win, and then the creative fails to serve and you get billed with nothing rendered. RTBlint validates responses on their own or cross-checked against the originating request, where every bid's &lt;code&gt;impid&lt;/code&gt;, &lt;code&gt;mtype&lt;/code&gt;, markup, deal ID, seat, and currency are verified against what the request actually offered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catching all of this before it ships
&lt;/h2&gt;

&lt;p&gt;Every finding above has a stable rule ID, a severity, a message, and a JSON path, which means you can gate a deploy on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo &lt;span class="nb"&gt;install &lt;/span&gt;rtblint
rtblint validate request.json
rtblint validate &lt;span class="nt"&gt;--type&lt;/span&gt; response &lt;span class="nt"&gt;--request&lt;/span&gt; request.json response.json
rtblint validate &lt;span class="nt"&gt;--version&lt;/span&gt; 2.6-202606 &lt;span class="nt"&gt;--format&lt;/span&gt; json request.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or from Node via WASM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;validate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rtblint-core&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bidRequest&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2.6-202505&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;issue&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also a &lt;a href="https://rtblint.org/tester/" rel="noopener noreferrer"&gt;browser tester&lt;/a&gt; that runs the same rules client-side (your payloads never leave the page), an &lt;a href="https://rtblint.org/docs/mcp/" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; if you want your coding agent to validate bid requests while it works, and the full &lt;a href="https://rtblint.org/docs/rules/" rel="noopener noreferrer"&gt;rule reference&lt;/a&gt; with fix guidance per rule. The validator covers every OpenRTB snapshot from 2.0 through 2.6-202606, plus 3.0.&lt;/p&gt;

&lt;p&gt;Source is on &lt;a href="https://github.com/aleksUIX/rtblint" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If you run traffic through it and hit a false positive, an issue with the payload shape is the fastest way to get it fixed.&lt;/p&gt;

&lt;p&gt;The pattern behind all ten mistakes is the same: OpenRTB does not tell you when you are wrong, it just pays you less. A linter in CI is the cheapest way to find out first.&lt;/p&gt;

</description>
      <category>advertising</category>
      <category>ctv</category>
      <category>webdev</category>
      <category>rust</category>
    </item>
    <item>
      <title>AAMP vs AdCP: The Two Standards Stacks Racing to Define Agentic Advertising</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Sun, 05 Jul 2026 22:29:10 +0000</pubDate>
      <link>https://dev.to/aleksuix/aamp-vs-adcp-the-two-standards-stacks-racing-to-define-agentic-advertising-3ogn</link>
      <guid>https://dev.to/aleksuix/aamp-vs-adcp-the-two-standards-stacks-racing-to-define-agentic-advertising-3ogn</guid>
      <description>&lt;p&gt;Eighteen months ago, agent-driven ad buying was a keynote topic. Today it is two competing standards stacks with shipped software, published by two different organizations that are openly not coordinating.&lt;/p&gt;

&lt;p&gt;If you build adtech, both are about to show up in your pipes. Here is the short version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack one: IAB Tech Lab's AAMP
&lt;/h2&gt;

&lt;p&gt;AAMP (Agentic Advertising Management Protocols) launched in January 2026 and hit 2.0 in June. The philosophy: build on the rails that already move money. It wires OpenRTB, AdCOM, OpenDirect, and VAST together, with MCP and Google's Agent2Agent protocol as the agent interfaces.&lt;/p&gt;

&lt;p&gt;Version 1.0 could only transact direct deals. The 2.0 release added programmatic: a Buyer Agent SDK with a three-layer agent hierarchy (orchestration, channel specialists, functional agents), a Seller Agent SDK that turns a static media kit into a storefront that adapts pricing to the buyer, and a Deals Library as the system of record with OpenDirect 2.1 support. Approval gates and audit logs keep humans at the sign-off points.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack two: AAO's AdCP
&lt;/h2&gt;

&lt;p&gt;The Ad Context Protocol launched in October 2025 from a consortium including Yahoo, PubMatic, Optable, Scope3, Swivel, and Triton Digital. It is now governed by AgenticAdvertising.Org (AAO), a trade association with four equally weighted voting classes: brands, agencies, publishers, and technology providers. The reference sell-side implementation lives with the Prebid community.&lt;/p&gt;

&lt;p&gt;AdCP was designed agent-native from the start. It is built directly on MCP, runs asynchronously so humans can approve while agents negotiate, and version 3.0 (April 2026) covers the full campaign lifecycle: discovery, media buys, creative production with brand.json, governance, and reporting across 20 media channels. It is in production at Snap, Pinterest, Reddit, Netflix, and Vox Media.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual differences
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Governance.&lt;/strong&gt; Tech Lab is the incumbent that already maintains VAST and OpenRTB. AAO is a new association built specifically for agentic advertising.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design center.&lt;/strong&gt; AAMP layers agents onto existing programmatic rails. AdCP defines new MCP-native tasks first and treats legacy systems as integration targets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coverage.&lt;/strong&gt; AAMP is deepest at the transaction layer, with ARTF agent containers running inside bidder infrastructure. AdCP is broadest across the lifecycle, planning through reporting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interfaces.&lt;/strong&gt; Both name MCP as a core protocol. A tool exposed as an MCP server serves agents on either stack without modification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification.&lt;/strong&gt; Neither stack validates the creative payload. Deal state and context move between agents; the correctness of what ships is out of scope for both specs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The layer they share
&lt;/h2&gt;

&lt;p&gt;Follow a video buy through either stack and the endpoint is identical: an ad server returns VAST XML. An InLine or Wrapper, MediaFiles, Impression pixels, TrackingEvents. Twenty channels of protocol surface on one side, one XML document on the other.&lt;/p&gt;

&lt;p&gt;That document is where delivery fails. A missing Duration, an HTTP media URL on a CTV device that requires HTTPS, a wrapper chain past the player's depth limit: none of it is visible at the protocol layer. The deal confirms cleanly, the agents log success, and the impression dies at runtime with a VAST error code nobody is watching.&lt;/p&gt;

&lt;p&gt;Human traffickers used to be the backstop. In an agent-to-agent transaction there is no trafficker. Validation has to be a tool the agent calls before the deal confirms, not a QA pass after launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical takeaway
&lt;/h2&gt;

&lt;p&gt;You do not need to bet on a winner. MCP is the shared interface: AAMP's Buyer Agent SDK accepts additional MCP tool servers, and AdCP is MCP-native end to end. Anything you expose over MCP works in both stacks today.&lt;/p&gt;

&lt;p&gt;That is how we ship &lt;a href="https://vastlint.org" rel="noopener noreferrer"&gt;vastlint&lt;/a&gt;: 187 rules grounded in the IAB VAST, OMID, and SIMID specs, deterministic JSON output, available as a hosted MCP endpoint, CLI, and libraries. Point either stack's agent at it and gate deal confirmation on a clean tag.&lt;/p&gt;

&lt;p&gt;The protocols will keep changing. The payload has been VAST for two decades and will still be VAST when the governance questions settle.&lt;/p&gt;

&lt;p&gt;The full comparison, with sources, is on the vastlint blog: &lt;a href="https://vastlint.org/blog/aamp-vs-adcp-agentic-advertising-standards/" rel="noopener noreferrer"&gt;AAMP vs AdCP: What IAB Tech Lab and AAO Are Each Building for Agentic Advertising&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Working with VAST tags?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://vastlint.org/validate/" rel="noopener noreferrer"&gt;Paste XML into the validator&lt;/a&gt; to check it against the full IAB rule catalog.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vastlint.org/tester/" rel="noopener noreferrer"&gt;Test a live tag URL&lt;/a&gt; and watch it render in a real player.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vastlint.org/inspect/" rel="noopener noreferrer"&gt;Walk a wrapper chain&lt;/a&gt; hop by hop to find where it breaks.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>advertising</category>
      <category>ctv</category>
      <category>ai</category>
      <category>iab</category>
    </item>
    <item>
      <title>The VAST Macros Cheat Sheet Every Ad Ops Engineer Need</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Sun, 28 Jun 2026 16:46:17 +0000</pubDate>
      <link>https://dev.to/aleksuix/the-vast-macros-cheat-sheet-every-ad-ops-engineer-need-44pe</link>
      <guid>https://dev.to/aleksuix/the-vast-macros-cheat-sheet-every-ad-ops-engineer-need-44pe</guid>
      <description>&lt;p&gt;If you have ever stared at a tracking URL full of &lt;code&gt;[CACHEBUSTING]&lt;/code&gt;, &lt;code&gt;[TIMESTAMP]&lt;/code&gt;, and &lt;code&gt;[ADPLAYHEAD]&lt;/code&gt; and wondered which of those the player actually fills in, this post is for you.&lt;/p&gt;

&lt;p&gt;VAST macros are substitution tokens of the form &lt;code&gt;[MACRO]&lt;/code&gt; that ad servers and players replace inside tracking, click, error, impression, and media URLs at request time. They are how a static VAST tag carries dynamic, per-impression context: the device ID, the consent string, the playhead position, the error code. Get them wrong and you lose attribution, break frequency capping, or fire tracking pixels that a cache silently collapses into one.&lt;/p&gt;

&lt;p&gt;I have debugged enough broken tags to know the failure modes are always the same handful. Here is the working reference, grouped the way you actually reason about them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model
&lt;/h2&gt;

&lt;p&gt;A macro only has a defined value in the context where the spec says it does. &lt;code&gt;[ERRORCODE]&lt;/code&gt; means nothing inside an Impression pixel. &lt;code&gt;[REASON]&lt;/code&gt; means nothing outside a &lt;code&gt;verificationNotExecuted&lt;/code&gt; URI. If you drop a macro into the wrong element, the player either leaves the literal &lt;code&gt;[ERRORCODE]&lt;/code&gt; string in the URL or substitutes an empty value, and your reporting goes sideways.&lt;/p&gt;

&lt;p&gt;The second rule: anything that is itself a URL or contains reserved characters must be percent-encoded when nested inside another URL. &lt;code&gt;[PAGEURL]&lt;/code&gt;, &lt;code&gt;[ASSETURI]&lt;/code&gt;, &lt;code&gt;[TIMESTAMP]&lt;/code&gt;, and the various user-agent macros are the usual offenders.&lt;/p&gt;

&lt;p&gt;That is most of the bugs right there. Context and encoding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache busting and timing
&lt;/h2&gt;

&lt;p&gt;The two macros that belong on nearly every tracking URL:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/cachebusting/" rel="noopener noreferrer"&gt;CACHEBUSTING&lt;/a&gt;&lt;/strong&gt; resolves to a fresh random 8-digit number per request. Without it, proxies and browser caches happily collapse repeated tracking calls into a single hit, and your impression counts come in low.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/timestamp/" rel="noopener noreferrer"&gt;TIMESTAMP&lt;/a&gt;&lt;/strong&gt; is the ISO 8601 time the request fired. Useful for ordering events and as a secondary cache buster. Percent-encode it, because the colons in &lt;code&gt;18:30:00Z&lt;/code&gt; are reserved.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Playhead: the one everyone gets wrong
&lt;/h2&gt;

&lt;p&gt;This is the single most common source of legacy-tag confusion.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/adplayhead/" rel="noopener noreferrer"&gt;ADPLAYHEAD&lt;/a&gt;&lt;/strong&gt; is the current position inside the ad creative, in &lt;code&gt;HH:MM:SS.mmm&lt;/code&gt;. This is the VAST 4.1 macro you should be using.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/contentplayhead/" rel="noopener noreferrer"&gt;CONTENTPLAYHEAD&lt;/a&gt;&lt;/strong&gt; is the pre-4.1 macro. It was ambiguous about whether it meant ad time or content time, so 4.1 deprecated it in favour of &lt;code&gt;[ADPLAYHEAD]&lt;/code&gt;. Newer players may not populate it at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your quartile reporting is empty on modern CTV players, a stale &lt;code&gt;[CONTENTPLAYHEAD]&lt;/code&gt; in your tracking URLs is the first thing to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Errors and verification
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/errorcode/" rel="noopener noreferrer"&gt;ERRORCODE&lt;/a&gt;&lt;/strong&gt; substitutes the numeric VAST error code (303, 401, and friends) and only has a defined value inside an &lt;code&gt;&amp;lt;Error&amp;gt;&lt;/code&gt; URI. Put it anywhere else and it stays literal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/reason/" rel="noopener noreferrer"&gt;REASON&lt;/a&gt;&lt;/strong&gt; carries why a verification script did not run (1 could not load, 2 could not verify, 3 rejected) and is only valid inside a &lt;code&gt;verificationNotExecuted&lt;/code&gt; tracking URI.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Identity and privacy
&lt;/h2&gt;

&lt;p&gt;Everything attribution and consent depends on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/ifa/" rel="noopener noreferrer"&gt;IFA&lt;/a&gt;&lt;/strong&gt; is the resettable device advertising identifier: IDFA on iOS/tvOS, AAID on Android, a platform ID on CTV. Empty when the user has opted out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/gdpr/" rel="noopener noreferrer"&gt;GDPR&lt;/a&gt;&lt;/strong&gt; is the 1/0 flag for whether GDPR applies, and &lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/gdprconsent/" rel="noopener noreferrer"&gt;GDPRCONSENT&lt;/a&gt;&lt;/strong&gt; carries the IAB TCF consent string. If &lt;code&gt;[GDPR]&lt;/code&gt; is 1 you should be sending a valid consent string alongside it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/limitadtracking/" rel="noopener noreferrer"&gt;LIMITADTRACKING&lt;/a&gt;&lt;/strong&gt; reports the device limit-ad-tracking setting. When it is 1, the &lt;code&gt;[IFA]&lt;/code&gt; is typically zeroed and personalised targeting must be suppressed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Context: where and how the ad served
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/deviceip/" rel="noopener noreferrer"&gt;DEVICEIP&lt;/a&gt;&lt;/strong&gt; is the end-user device IP, central to geolocation and fraud detection in server-side flows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/domain/" rel="noopener noreferrer"&gt;DOMAIN&lt;/a&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/pageurl/" rel="noopener noreferrer"&gt;PAGEURL&lt;/a&gt;&lt;/strong&gt; identify the supply. Remember to percent-encode &lt;code&gt;[PAGEURL]&lt;/code&gt; when it rides inside another URL as a query parameter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/playersize/" rel="noopener noreferrer"&gt;PLAYERSIZE&lt;/a&gt;&lt;/strong&gt; gives &lt;code&gt;width,height&lt;/code&gt; for viewability context and creative selection.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Identifiers that tie the supply chain together
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/adservingid/" rel="noopener noreferrer"&gt;ADSERVINGID&lt;/a&gt;&lt;/strong&gt; is a single identifier from the InLine ad that every party in the chain can log, which is the fastest path to debugging a discrepancy across SSP, DSP, and verification logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/universaladid/" rel="noopener noreferrer"&gt;UNIVERSALADID&lt;/a&gt;&lt;/strong&gt; uniquely identifies the creative across systems for creative-level frequency capping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://vastlint.org/docs/vast-macros/adcount/" rel="noopener noreferrer"&gt;ADCOUNT&lt;/a&gt;&lt;/strong&gt; is the ad's position within a pod, for pod-level reporting and pacing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The deprecated ones still hiding in production
&lt;/h2&gt;

&lt;p&gt;VAST 4.1 deprecated the older playhead macros. &lt;code&gt;[CONTENTPLAYHEAD]&lt;/code&gt; and &lt;code&gt;[MEDIAPLAYHEAD]&lt;/code&gt; were both folded into &lt;code&gt;[ADPLAYHEAD]&lt;/code&gt;. They still appear constantly in tags copied from older ad servers, and they are a silent failure: the URL looks fine, but the value never arrives on a player that only implements the 4.1 macro.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually catch these
&lt;/h2&gt;

&lt;p&gt;Reading macro tables by hand does not scale past a few tags. The cases that bite are the boring ones: a macro in the wrong element, an unencoded URL, a deprecated token nobody noticed. Those are exactly what a linter is good at.&lt;/p&gt;

&lt;p&gt;I run tags through &lt;a href="https://vastlint.org/" rel="noopener noreferrer"&gt;vastlint&lt;/a&gt;, an open VAST validator that flags unknown macros, lowercase casing mistakes, missing percent-encoding, deprecated tokens, and context violations (an &lt;code&gt;[ERRORCODE]&lt;/code&gt; outside &lt;code&gt;&amp;lt;Error&amp;gt;&lt;/code&gt;, a &lt;code&gt;[REASON]&lt;/code&gt; outside &lt;code&gt;verificationNotExecuted&lt;/code&gt;). The full per-macro reference, with the value each one resolves to and where it is valid, lives at &lt;a href="https://vastlint.org/docs/vast-macros/" rel="noopener noreferrer"&gt;vastlint.org/docs/vast-macros&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you maintain VAST tags, bookmark the macro index and wire the validator into CI. The discrepancies you prevent are the ones you never have to explain on a reconciliation call.&lt;/p&gt;




&lt;p&gt;Working with VAST tags?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://vastlint.org/validate/" rel="noopener noreferrer"&gt;Paste XML into the validator&lt;/a&gt; to check it against the full IAB rule catalog.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vastlint.org/tester/" rel="noopener noreferrer"&gt;Test a live tag URL&lt;/a&gt; and watch it render in a real player.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vastlint.org/inspect/" rel="noopener noreferrer"&gt;Walk a wrapper chain&lt;/a&gt; hop by hop to find where it breaks.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ctv</category>
      <category>adtech</category>
      <category>advertising</category>
      <category>iab</category>
    </item>
  </channel>
</rss>
