<?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>hp=0 in SupplyChain 1.1 Marks Custody Hops. ads.txt Checks on Them False-Fail.</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Sun, 06 Sep 2026 00:40:32 +0000</pubDate>
      <link>https://dev.to/aleksuix/hp0-in-supplychain-11-marks-custody-hops-adstxt-checks-on-them-false-fail-360p</link>
      <guid>https://dev.to/aleksuix/hp0-in-supplychain-11-marks-custody-hops-adstxt-checks-on-them-false-fail-360p</guid>
      <description>&lt;p&gt;A CTV bid request arrives with a three-node &lt;code&gt;source.schain&lt;/code&gt;. The middle node names an SSAI platform the buyer has never heard of. Your supply-path optimizer flags it: &lt;code&gt;sid&lt;/code&gt; not in sellers.json, ads.txt lookup fails, chain rejected.&lt;/p&gt;

&lt;p&gt;The partner adopted SupplyChain 1.1 early. That middle node carries &lt;code&gt;hp: 0&lt;/code&gt;. It was never supposed to pass ads.txt. Your reader treats it like a payment hop because that is what six years of 1.0 chains trained everyone to assume.&lt;/p&gt;

&lt;h2&gt;
  
  
  What hp was supposed to mean
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;hp&lt;/code&gt; field on a &lt;code&gt;SupplyChainNode&lt;/code&gt; is an integer: 1 if this node is involved in the flow of payment, 0 if not. Required on every node.&lt;/p&gt;

&lt;p&gt;SupplyChain 1.0 then adds the sentence that shaped every implementation since: for version 1.0, this property should always be 1. The flag became a boolean with one legal value. Builders hardcoded &lt;code&gt;hp: 1&lt;/code&gt;. Validators asserted it. Buy-side audit walked every node against sellers.json and ads.txt because, in a 1.0 chain, every node is a seller account.&lt;/p&gt;

&lt;p&gt;The result is a chain that describes money and nothing else. A request that crossed a header-bidding wrapper, a publisher ad server, an SSAI stitcher, and two SDKs might disclose two nodes. Both are payment nodes. Four systems took custody of the payload. None of them are named.&lt;/p&gt;

&lt;p&gt;SupplyChain 1.1 (IAB Tech Lab public comment, June 2026) keeps the same &lt;code&gt;nodes&lt;/code&gt; array and lets custody-taking systems append themselves with &lt;code&gt;hp: 0&lt;/code&gt;. Prebid, ad servers, SSAI platforms, SDKs: the plumbing everybody runs and nobody currently discloses.&lt;/p&gt;

&lt;p&gt;One clause in the proposal matters for anyone running authorization checks: &lt;code&gt;hp=0&lt;/code&gt; nodes are not expected to pass ads.txt validation. Referencing a sellers.json entry for a non-payment handler is strongly recommended, not required. A custody node is disclosure, not an authorization claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chain before and after
&lt;/h2&gt;

&lt;p&gt;A publisher ad server, an SSAI platform, and an exchange on the same impression:&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="nl"&gt;"source"&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;"schain"&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;"ver"&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.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;"complete"&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;"nodes"&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;span class="nl"&gt;"asi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pubadserver.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8842"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hp"&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="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="nl"&gt;"asi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"exchange.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pub-42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hp"&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="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;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;Same path under 1.1:&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="nl"&gt;"source"&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;"schain"&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;"ver"&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.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;"complete"&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;"nodes"&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;span class="nl"&gt;"asi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pubadserver.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8842"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hp"&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="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="nl"&gt;"asi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ssai.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"net-19"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hp"&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="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="nl"&gt;"asi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"exchange.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pub-42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hp"&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="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;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;Three consequences fall out immediately. A supply-path model that scores by chain length now sees a longer path for the same inventory and will penalize the more honest disclosure unless it filters on &lt;code&gt;hp&lt;/code&gt; first. Duplicate bid detection gets better: the same request arriving through two routes now carries evidence of which systems it crossed. An authorization check that walks all three nodes against ads.txt finds one that was never supposed to be there.&lt;/p&gt;

&lt;p&gt;That third outcome is the bug I keep seeing in production readers, not in the payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  What your validator actually checks today
&lt;/h2&gt;

&lt;p&gt;Under SupplyChain 1.0 rules, a node with &lt;code&gt;hp: 0&lt;/code&gt; triggers &lt;code&gt;openrtb.schain.node.hp_unexpected&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;rtblint validate bid-request.json
&lt;span class="go"&gt;warning  openrtb.schain.node.hp_unexpected
&lt;/span&gt;&lt;span class="gp"&gt;         hp is 0;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;version 1.0 of the SupplyChain object expects every node
&lt;span class="go"&gt;         on the declared path to be marked as part of the payment flow
         with hp set to 1.
         path: source.schain.nodes[0].hp
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That warning is correct for a chain that declares &lt;code&gt;ver: "1.0"&lt;/code&gt;. It is the wrong verdict for a chain that declares &lt;code&gt;ver: "1.1"&lt;/code&gt; and uses &lt;code&gt;hp&lt;/code&gt; as real data.&lt;/p&gt;

&lt;p&gt;The structural checks survive the migration: &lt;code&gt;asi&lt;/code&gt; and &lt;code&gt;sid&lt;/code&gt; required, adjacent duplicate nodes still fail (&lt;code&gt;openrtb.schain.duplicate_node&lt;/code&gt;), &lt;code&gt;complete&lt;/code&gt; still means something (though new enumerations are proposed, so read the final text before branching on it). What breaks is anything that assumed every node resolves in sellers.json or passes ads.txt.&lt;/p&gt;

&lt;p&gt;When you run resolution with a local cache, RTBlint skips sellers.json lookup on &lt;code&gt;hp=0&lt;/code&gt; nodes entirely. Only payment hops get &lt;code&gt;openrtb.resolve.sid_not_in_sellers&lt;/code&gt;. That matches the spec intent: authorization applies to the payment path, not to custody disclosure.&lt;/p&gt;

&lt;h2&gt;
  
  
  How you catch it before a partner ships 1.1
&lt;/h2&gt;

&lt;p&gt;Paste the bid request into the &lt;a href="https://rtblint.org/tester/" rel="noopener noreferrer"&gt;OpenRTB tester&lt;/a&gt;. It flags structural schain issues with the JSON path attached, including chains left in the pre-2.6 &lt;code&gt;source.ext.schain&lt;/code&gt; location (&lt;code&gt;openrtb.field.moved&lt;/code&gt;) and misspelled node fields (&lt;code&gt;openrtb.field.undefined&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;For a corpus of what your stack actually emits:&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;--resolve&lt;/span&gt; &lt;span class="nt"&gt;--cache&lt;/span&gt; ./trust-cache/ ./outbound/&lt;span class="k"&gt;*&lt;/span&gt;.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same checks run as MCP tools an agent can call on its own output, and over gRPC on &lt;code&gt;openadtech.rtblint.v1&lt;/code&gt;. Agentic stacks (AAMP buyer agents, ARTF hosts) still end the hop as an OpenRTB bid request. The agent handshake succeeding does not mean &lt;code&gt;source.schain&lt;/code&gt; matches the reader on the other side.&lt;/p&gt;

&lt;p&gt;Grep your codebase for the habits that will false-fail honest 1.1 chains:&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="s"&gt;hp.*[:=]\s*1&lt;/span&gt;          &lt;span class="c1"&gt;# hardcoded payment claims&lt;/span&gt;
&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.0"&lt;/span&gt;                &lt;span class="c1"&gt;# ver comparisons that silently drop 1.1&lt;/span&gt;
&lt;span class="s"&gt;schain.*length&lt;/span&gt;        &lt;span class="c1"&gt;# hop-count policies without an hp filter&lt;/span&gt;
&lt;span class="s"&gt;adsTxt|ads_txt&lt;/span&gt;        &lt;span class="c1"&gt;# authorization scoped chain-wide&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every hit is a decision, not a mechanical find-and-replace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://rtblint.org/blog/supplychain-1-1-hp-zero-nodes/" rel="noopener noreferrer"&gt;SupplyChain 1.1 analysis&lt;/a&gt; walks the check-by-check split between 1.0 assumptions and 1.1 semantics: which rules survive, which ones were wearing a 1.0 assumption as clothing.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://rtblint.org/guides/openrtb-schain-1-1-migration/" rel="noopener noreferrer"&gt;migration checklist&lt;/a&gt; separates sell-side builder steps from buy-side reader steps. If you read chains for SPO or verification, the reader half is the one that prevents manufactured violations on &lt;code&gt;hp=0&lt;/code&gt; nodes.&lt;/p&gt;

&lt;p&gt;Field definitions and the 2.5 vs 2.6 placement (&lt;code&gt;source.schain&lt;/code&gt; vs &lt;code&gt;source.ext.schain&lt;/code&gt;) live in the &lt;a href="https://rtblint.org/docs/openrtb/supply-chain/" rel="noopener noreferrer"&gt;schain reference&lt;/a&gt;. Overclaiming completeness is its own failure mode: &lt;a href="https://rtblint.org/docs/rule-reference/openrtb-schain-incomplete/" rel="noopener noreferrer"&gt;&lt;code&gt;openrtb.schain.incomplete&lt;/code&gt;&lt;/a&gt; fires when &lt;code&gt;complete: 1&lt;/code&gt; does not match what the nodes actually disclose.&lt;/p&gt;

&lt;p&gt;For how schain sits beside ads.txt, sellers.json, and the newer trust artifacts, the &lt;a href="https://rtblint.org/guides/openrtb-supply-chain-trust-stack/" rel="noopener noreferrer"&gt;supply chain trust stack guide&lt;/a&gt; maps which file answers which question. None of them cross-check each other automatically; schain records the path, the other files authorize it.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;hp=0&lt;/code&gt; is not a broken seller ID. It is a custody hop that never touches payment. Running ads.txt or mandatory sellers.json resolution on those nodes rejects good supply. Split hop counting from money counting. Accept &lt;code&gt;ver: "1.1"&lt;/code&gt; or reject it loudly. The silent third option, dropping an unrecognized version and proceeding without the chain, is worse than either.&lt;/p&gt;

&lt;p&gt;RTBlint is independent of IAB Tech Lab. The rule ids above cite the SupplyChain object spec and the 1.1 public comment proposal because that is where the requirements live.&lt;/p&gt;

</description>
      <category>openrtb</category>
      <category>adtech</category>
      <category>advertising</category>
      <category>api</category>
    </item>
    <item>
      <title>Reject on the Banner Can Still Be Grant in gdpr_consent. Withdraw Has to Change the Hop.</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Sat, 05 Sep 2026 00:38:59 +0000</pubDate>
      <link>https://dev.to/aleksuix/reject-on-the-banner-can-still-be-grant-in-gdprconsent-withdraw-has-to-change-the-hop-gj2</link>
      <guid>https://dev.to/aleksuix/reject-on-the-banner-can-still-be-grant-in-gdprconsent-withdraw-has-to-change-the-hop-gj2</guid>
      <description>&lt;p&gt;QA clicks Accept, copies the Floodlight hop from DevTools, clicks Reject, copies again, and compares screenshots. The banner flipped. The two URLs are identical.&lt;/p&gt;

&lt;p&gt;That is not a flaky CMP skin. The vendor that received the second hop received the same grant string it got on accept. GDPR says consent must be as easy to withdraw as to give. The wire half of that sentence is not the modal copy. It is whether the next pixel, collect ping, or CAPI post carries a different payload, or no payload at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accept is already instrumented. Withdraw usually is not
&lt;/h2&gt;

&lt;p&gt;The grant path is wired. A CMP callback expands &lt;code&gt;gdpr_consent&lt;/code&gt;, &lt;code&gt;gtag('consent', 'update', …)&lt;/code&gt; runs, Meta gets an &lt;code&gt;fbp&lt;/code&gt; write, Floodlight fires with &lt;code&gt;gdpr=1&lt;/code&gt; and a TC String. Teams paste that URL into tickets and call consent done.&lt;/p&gt;

&lt;p&gt;Withdraw is supposed to be the same class of artifact with different bits, or silence. If the only thing that changes is banner text, every vendor that heard yes keeps processing on the old string. Cookies written on grant still exist. Pixels that read &lt;code&gt;_fbp&lt;/code&gt; or a CMP cookie do not poll the banner. They read the next HTTP request.&lt;/p&gt;

&lt;p&gt;Researchers auditing Tranco top sites treated revocation as its own object, not the inverse screenshot of accept. On TCF sites they could read after revoke, 22 of 136 still stored a TC String that encoded positive consent. That is not a missing parameter. That is a yes that outlived the user's no. On another subsample, third parties that had been told about accept were not informed of withdraw on 101 of 136 sites. Vendors do not call &lt;code&gt;__tcfapi&lt;/code&gt; on a schedule. They see the hop you fire, or they do not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gdpr_consent actually carries
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;gdpr_consent&lt;/code&gt; is not a boolean. It is a URL-safe base64 TC String whose core segment fixes version 2 in the first six bits, then encodes purpose consent, legitimate interest, vendor flags, and timestamps in the bit field IAB Europe documents for TCF v2.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gdpr_consent=1&lt;/code&gt; passes a character-set check and fails the contract: those bits decode to version 1, not a TC String. A real string that still encodes grant after reject is a different failure. The alphabet looks like TCF. The purposes still read yes.&lt;/p&gt;

&lt;p&gt;The macro name matters too. &lt;code&gt;${GDPR_CONSENT_123}&lt;/code&gt; must expand with vendor 123's GVL id in the macro suffix. A template that still holds &lt;code&gt;[GDPR_CONSENT_123]&lt;/code&gt; at serve time is empty consent. A populated grant macro after revoke is worse: the ad server did expand something, and what it expanded is still yes.&lt;/p&gt;

&lt;p&gt;Floodlight, Meta &lt;code&gt;/tr&lt;/code&gt;, LinkedIn pixels, and server-side CAPI each need their own signal or their own hold. Consent Mode on gtag does not write &lt;code&gt;gdpr_consent&lt;/code&gt;. &lt;code&gt;dpo=LDU&lt;/code&gt; does not write a TC String. A Purchase queued at accept and retried after revoke is new processing unless the webhook gates on the current CMP state.&lt;/p&gt;

&lt;h2&gt;
  
  
  How you catch it
&lt;/h2&gt;

&lt;p&gt;Do not stop QA at refuse-on-landing in a fresh profile. Run the returning-user sequence: accept, capture, revoke, capture again.&lt;/p&gt;

&lt;p&gt;After revoke, &lt;code&gt;facebook.com/tr&lt;/code&gt;, a Floodlight activity, and &lt;code&gt;/g/collect&lt;/code&gt; should be gone, redacted, or carrying a string that no longer encodes grant. If they match the accept capture byte for byte, withdraw did not travel. Redirect chains matter: if hop two drops the updated string, hop three is still the old grant.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://github.com/aleksUIX/pixellint" rel="noopener noreferrer"&gt;Pixellint&lt;/a&gt;, an open source linter for pixels and conversion API payloads. The core privacy pack decodes &lt;code&gt;gdpr_consent&lt;/code&gt; and &lt;code&gt;gpp&lt;/code&gt; header bits on live URLs. It will not delete cookies or rule on lawful basis. It will tell you whether the URL you captured after revoke is still a granted IAB hop.&lt;/p&gt;

&lt;p&gt;On a placeholder after accept or revoke:&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;pixellint validate url &lt;span class="s1"&gt;'https://ad.doubleclick.net/ddm/activity/src=1234567;type=convr0;cat=purch0;gdpr=1;gdpr_consent=1'&lt;/span&gt;
&lt;span class="go"&gt;rulepack: core
  error   core.privacy.tc_string_version
          `gdpr_consent` is `1`, whose first six bits decode to version 1.
          The TC String spec fixes the version to 2, so this is not a TC String.
    fix:  Pass the TC String from the CMP. A placeholder such as `1` is
          base64-shaped but carries no consent.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a syntactically valid string, core checks pass. That is where you compare captures. Paste the post-revoke Floodlight URL into the &lt;a href="https://pixellint.org/" rel="noopener noreferrer"&gt;playground&lt;/a&gt; beside the accept capture. Same core segment, same purpose bits: the banner lost to the macro. Different &lt;code&gt;LastUpdated&lt;/code&gt; deciseconds with grant still set: the CMP updated the timestamp without flipping consent.&lt;/p&gt;

&lt;p&gt;Server-side GTM and CAPI get the second paste too. A Graph 200 after revoke means Meta accepted JSON. It does not mean you had a basis to send it.&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;"data"&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;"event_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Purchase"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"event_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1770000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"action_source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"website"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"user_data"&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;"em"&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;"7b52009b64fd0a2a49e6d8a939753079792b5a1"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"fbp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fb.1.1770000000.123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"client_ip_address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"203.0.113.42"&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;pixellint validate json&lt;/code&gt; on that body after revoke flags shape errors (seconds on &lt;code&gt;event_time&lt;/code&gt;, hashed IP rules) and missing website fields. It does not know your CMP state. You gate the webhook: when the basis is gone, skip the postback or send a redacted event. Meta documents &lt;code&gt;opt_out&lt;/code&gt; for attribution-only use; that is not a substitute for stopping match keys you no longer have permission to send.&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;pixellint
&lt;span class="c"&gt;# or: npm install pixellint&lt;/span&gt;
pixellint validate url &lt;span class="s1"&gt;'https://...'&lt;/span&gt; 
pixellint validate json @capi-after-revoke.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://pixellint.org/docs/consent-revocation-on-the-wire/" rel="noopener noreferrer"&gt;consent revocation guide&lt;/a&gt; walks the full accept-then-revoke pass: cookies that survive reject, third parties that never hear no, and why a CMP callback update alone is incomplete.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pixellint.org/docs/tcf-v2/" rel="noopener noreferrer"&gt;TCF v2 on pixels&lt;/a&gt; covers &lt;code&gt;gdpr=0&lt;/code&gt; vs &lt;code&gt;gdpr=1&lt;/code&gt;, macro expansion, truncation, and sunset v1 strings. That is the accept-side contract this article assumes you already fire correctly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pixellint.org/docs/pixels/" rel="noopener noreferrer"&gt;What counts as a pixel&lt;/a&gt; separates impression beacons from click trackers and from CAPI posts so you know which artifact must change on withdraw for each vendor.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://pixellint.org/packs/meta-conversions-api/" rel="noopener noreferrer"&gt;Meta Conversions API pack&lt;/a&gt; lists &lt;code&gt;event_source_url&lt;/code&gt;, hashing rules, and &lt;code&gt;opt_out&lt;/code&gt; with citations. Use it on the server body in the same session where you lint the browser hop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pixellint.org/docs/process/" rel="noopener noreferrer"&gt;Process docs&lt;/a&gt; describe storing the CMP choice with the session so retries and batch jobs do not replay grant-era payloads after reject.&lt;/p&gt;

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

&lt;p&gt;Withdraw is a request class, not a banner state. Capture the pixel URL after accept and after revoke; they must differ, or the revoke hop must be absent. Decode &lt;code&gt;gdpr_consent&lt;/code&gt; on both. A grant string after reject is a fail even if the UI flipped. Repeat for CAPI JSON and for every third party that heard yes. Fix the CMP callback, the ad-server macro, or the webhook gate, not the screenshot.&lt;/p&gt;

&lt;p&gt;Pixellint is independent of IAB Europe and of the audit literature cited on our docs site. The rule ids above cite the TC String spec and vendor parameter docs because that is where the wire contract lives.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>gdpr</category>
      <category>advertising</category>
      <category>privacy</category>
    </item>
    <item>
      <title>A VAST Tag Can Ship AdVerifications and Still Deliver Zero OMID Measurement</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Fri, 04 Sep 2026 00:13:55 +0000</pubDate>
      <link>https://dev.to/aleksuix/a-vast-tag-can-ship-adverifications-and-still-deliver-zero-omid-measurement-3bjm</link>
      <guid>https://dev.to/aleksuix/a-vast-tag-can-ship-adverifications-and-still-deliver-zero-omid-measurement-3bjm</guid>
      <description>&lt;p&gt;The CTV campaign cleared delivery. The ad server counted the impression. The verification partner's dashboard stayed empty for three days before anyone opened the VAST XML.&lt;/p&gt;

&lt;p&gt;The tag was not missing measurement. It had an &lt;code&gt;&amp;lt;AdVerifications&amp;gt;&lt;/code&gt; block, a &lt;code&gt;&amp;lt;Verification&amp;gt;&lt;/code&gt; node, and a URL inside &lt;code&gt;&amp;lt;JavaScriptResource&amp;gt;&lt;/code&gt;. Visually, the buy included Open Measurement. On the wire, the OM SDK had nothing it could bind to.&lt;/p&gt;

&lt;p&gt;That gap is common enough that I treat &lt;code&gt;&amp;lt;AdVerifications&amp;gt;&lt;/code&gt; as a checklist, not a boolean. VAST 4.1 introduced the block when IAB moved verification off VPAID. The video creative and the measurement script are separate jobs now. SIMID handles interactivity. OMID handles viewability and IVT through the Open Measurement SDK. A tag that still routes measurement through a VPAID &lt;code&gt;&amp;lt;MediaFile&amp;gt;&lt;/code&gt; is on the wrong path entirely; the &lt;a href="https://vastlint.org/docs/vpaid/" rel="noopener noreferrer"&gt;VPAID reference&lt;/a&gt; and the &lt;a href="https://vastlint.org/docs/vpaid-deprecated/" rel="noopener noreferrer"&gt;deprecation timeline&lt;/a&gt; spell out why CTV inventory rejects it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AdVerifications actually promises
&lt;/h2&gt;

&lt;p&gt;Under &lt;code&gt;&amp;lt;InLine&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;Wrapper&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;AdVerifications&amp;gt;&lt;/code&gt; holds one or more &lt;code&gt;&amp;lt;Verification&amp;gt;&lt;/code&gt; elements. Each verification is a contract with four parts the player and OM SDK read together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;vendor&lt;/code&gt;&lt;/strong&gt; on &lt;code&gt;&amp;lt;Verification&amp;gt;&lt;/code&gt;: a domain-qualified key such as &lt;code&gt;measureco.com-omid&lt;/code&gt;. Required in VAST 4.1+. Without it, the session cannot be attributed to a measurement provider even if a script loads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A loadable resource&lt;/strong&gt;: &lt;code&gt;&amp;lt;JavaScriptResource&amp;gt;&lt;/code&gt; for web and most OM SDK integrations, or &lt;code&gt;&amp;lt;ExecutableResource&amp;gt;&lt;/code&gt; on native paths. VAST 4.1 requires &lt;code&gt;apiFramework&lt;/code&gt; on both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;apiFramework="omid"&lt;/code&gt;&lt;/strong&gt;: lowercase, exactly. This is not the same attribute as &lt;code&gt;apiFramework="SIMID"&lt;/code&gt; on &lt;code&gt;&amp;lt;InteractiveCreativeFile&amp;gt;&lt;/code&gt;. SIMID is the interactive layer; OMID is the verification layer. They solve different problems and live in different elements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;&amp;lt;VerificationParameters&amp;gt;&lt;/code&gt;&lt;/strong&gt;: bootstrap JSON or key/value data the vendor's script expects at init. Empty or missing parameters are schema-legal and still leave the script with no config.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;VPAID used to hide all of this inside one JavaScript &lt;code&gt;&amp;lt;MediaFile&amp;gt;&lt;/code&gt;. The &lt;a href="https://vastlint.org/guides/vast-vpaid-migration/" rel="noopener noreferrer"&gt;migration guide&lt;/a&gt; splits the work: interactivity to SIMID, verification to &lt;code&gt;&amp;lt;AdVerifications&amp;gt;&lt;/code&gt;. Tags that only half-migrate often carry both paths, which is its own class of failure on CTV.&lt;/p&gt;

&lt;p&gt;Here is the shape that looks complete and is not:&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;AdVerifications&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Verification&lt;/span&gt; &lt;span class="na"&gt;vendor=&lt;/span&gt;&lt;span class="s"&gt;"company.com-omid"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;JavaScriptResource&amp;gt;&lt;/span&gt;
      &lt;span class="cp"&gt;&amp;lt;![CDATA[https://verificationvendor.com/omid.js]]&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/JavaScriptResource&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/Verification&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/AdVerifications&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The URL is there. The OM SDK does not treat this as an OMID resource because &lt;code&gt;apiFramework&lt;/code&gt; is missing. vastlint reports &lt;a href="https://vastlint.org/docs/rules/VAST-4.1-js-resource-apiframework/" rel="noopener noreferrer"&gt;&lt;code&gt;VAST-4.1-js-resource-apiframework&lt;/code&gt;&lt;/a&gt; as an error: the attribute is required on verification resources in 4.1+.&lt;/p&gt;

&lt;p&gt;The fix is not decorative:&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;Verification&lt;/span&gt; &lt;span class="na"&gt;vendor=&lt;/span&gt;&lt;span class="s"&gt;"company.com-omid"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;JavaScriptResource&lt;/span&gt; &lt;span class="na"&gt;apiFramework=&lt;/span&gt;&lt;span class="s"&gt;"omid"&lt;/span&gt; &lt;span class="na"&gt;browserOptional=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;![CDATA[https://verificationvendor.com/omid.js]]&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/JavaScriptResource&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;VerificationParameters&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;![CDATA[{"partner":"abc123"}]]&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/VerificationParameters&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Verification&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The failures that survive a visual QA pass
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Missing or wrong &lt;code&gt;apiFramework&lt;/code&gt;.&lt;/strong&gt; Omitting the attribute is an error. Setting &lt;code&gt;apiFramework="OMID"&lt;/code&gt; or &lt;code&gt;apiFramework="VPAID"&lt;/code&gt; is XML that validates in some pipelines and fails at runtime. The OM SDK match is lowercase &lt;code&gt;omid&lt;/code&gt;. &lt;a href="https://vastlint.org/docs/rules/VAST-4.1-js-resource-apiframework-value/" rel="noopener noreferrer"&gt;&lt;code&gt;VAST-4.1-js-resource-apiframework-value&lt;/code&gt;&lt;/a&gt; fires when the value is anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verification shell with no resource.&lt;/strong&gt; Trafficking sometimes inserts &lt;code&gt;&amp;lt;AdVerifications&amp;gt;&lt;/code&gt; during creative build and never injects the vendor script URL. You get &lt;code&gt;&amp;lt;Verification vendor="company.com-omid"&amp;gt;&lt;/code&gt; with no &lt;code&gt;&amp;lt;JavaScriptResource&amp;gt;&lt;/code&gt; and no &lt;code&gt;&amp;lt;ExecutableResource&amp;gt;&lt;/code&gt;. The structure exists. Nothing executes. Buyers blame the publisher; the XML shows an empty node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HTTP verification URLs.&lt;/strong&gt; Mixed content blocks the script on HTTPS inventory. The linear MP4 plays. Measurement is blank with no player error. Same class of silent failure as SIMID interactive URLs; the &lt;a href="https://vastlint.org/docs/simid-rules/" rel="noopener noreferrer"&gt;SIMID rule reference&lt;/a&gt; covers the interactive side, while OMID rules such as &lt;code&gt;VAST-4.1-js-resource-https&lt;/code&gt; cover verification scripts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Empty &lt;code&gt;&amp;lt;VerificationParameters&amp;gt;&lt;/code&gt;.&lt;/strong&gt; Vendors ship init data in this element: partner IDs, feature flags, session hints. An empty tag is often still schema-valid. &lt;a href="https://vastlint.org/docs/rules/VAST-4.1-verification-parameters/" rel="noopener noreferrer"&gt;&lt;code&gt;VAST-4.1-verification-parameters&lt;/code&gt;&lt;/a&gt; warns because the script starts without the config it was written for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate &lt;code&gt;vendor&lt;/code&gt; values.&lt;/strong&gt; Two &lt;code&gt;&amp;lt;Verification&amp;gt;&lt;/code&gt; entries with the same &lt;code&gt;vendor&lt;/code&gt; string can spawn conflicting OM SDK sessions. Dedup matters when wrappers chain and each hop adds its own block.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrapper chains that drop verification.&lt;/strong&gt; &lt;code&gt;&amp;lt;AdVerifications&amp;gt;&lt;/code&gt; on a wrapper should pass verification metadata to the resolved inline. If your QA stops at the first hop, you can approve a wrapper that never forwards the vendor script the inline actually serves. That is a chain problem, not a creative problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I catch it before the campaign runs
&lt;/h2&gt;

&lt;p&gt;I start with the live tag URL in the &lt;a href="https://vastlint.org/tester/" rel="noopener noreferrer"&gt;VAST tag tester&lt;/a&gt;. It fetches the response, renders the linear creative, and surfaces tracking and click URLs. AdVerifications shows up in the parsed tree. I can see whether &lt;code&gt;&amp;lt;JavaScriptResource&amp;gt;&lt;/code&gt; declares &lt;code&gt;apiFramework="omid"&lt;/code&gt; and whether a URL is present before anyone presses play in a staging player.&lt;/p&gt;

&lt;p&gt;Wrapper-heavy CTV paths go through the &lt;a href="https://vastlint.org/inspect/" rel="noopener noreferrer"&gt;VAST inspector&lt;/a&gt; next. It follows each &lt;code&gt;&amp;lt;VASTAdTagURI&amp;gt;&lt;/code&gt; hop and shows where the chain breaks, how deep it ran, and which inline finally handed back creatives. Verification stripped at hop two is invisible if you only ever pasted the top-level XML.&lt;/p&gt;

&lt;p&gt;After that, CLI or CI:&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 omid-tag.xml
&lt;span class="go"&gt;omid-tag.xml  VAST 4.1
&lt;/span&gt;&lt;span class="gp"&gt;  error    &amp;lt;JavaScriptResource&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;is missing required apiFramework attribute
&lt;span class="go"&gt;           VAST-4.1-js-resource-apiframework
&lt;/span&gt;&lt;span class="gp"&gt;  warning  OMID &amp;lt;Verification&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;should include non-empty &amp;lt;VerificationParameters&amp;gt;
&lt;span class="go"&gt;           VAST-4.1-verification-parameters
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://vastlint.org/docs/vast-examples/verification-resource-apiframework/" rel="noopener noreferrer"&gt;broken example for missing apiFramework&lt;/a&gt; walks through the same tag with the full finding list. The &lt;a href="https://vastlint.org/docs/omid/" rel="noopener noreferrer"&gt;OMID overview&lt;/a&gt; maps each element back to VAST 4.1 §3.17 if you need spec anchors for a ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;AdVerifications&amp;gt;&lt;/code&gt; is not proof that viewability ran. It is instructions for the player to load a vendor script in an OM SDK sandbox. SSAI and many CTV native players never execute that JavaScript at all; verification has to be negotiated at the deal and player level, not assumed from XML presence.&lt;/p&gt;

&lt;p&gt;SIMID interactivity does not substitute. A tag with a correct &lt;code&gt;&amp;lt;InteractiveCreativeFile apiFramework="SIMID"&amp;gt;&lt;/code&gt; and a broken verification block still plays the interactive creative and still fails independent measurement.&lt;/p&gt;

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

&lt;p&gt;Presence of &lt;code&gt;&amp;lt;AdVerifications&amp;gt;&lt;/code&gt; is not measurement. Every &lt;code&gt;&amp;lt;Verification&amp;gt;&lt;/code&gt; needs &lt;code&gt;vendor&lt;/code&gt;, a resource with &lt;code&gt;apiFramework="omid"&lt;/code&gt;, and the vendor's &lt;code&gt;&amp;lt;VerificationParameters&amp;gt;&lt;/code&gt;. Test the live URL, walk wrapper chains, then lint the XML. The video will play either way. The buyer's verification invoice is what tells you the contract was hollow.&lt;/p&gt;

&lt;p&gt;vastlint is independent of IAB Tech Lab. The rule ids above cite VAST 4.1 because that is where the requirements live, not because this is an official tool.&lt;/p&gt;

</description>
      <category>vast</category>
      <category>javascript</category>
      <category>advertising</category>
      <category>ctv</category>
    </item>
    <item>
      <title>OpenRTB 3.0 Placement With Only tagid Has No AdCOM Subtype</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Thu, 03 Sep 2026 00:35:57 +0000</pubDate>
      <link>https://dev.to/aleksuix/openrtb-30-placement-with-only-tagid-has-no-adcom-subtype-4o41</link>
      <guid>https://dev.to/aleksuix/openrtb-30-placement-with-only-tagid-has-no-adcom-subtype-4o41</guid>
      <description>&lt;p&gt;A supply-side adapter ships its first OpenRTB 3.0 bid request. The envelope validates. &lt;code&gt;domainspec&lt;/code&gt; is &lt;code&gt;adcom&lt;/code&gt;. Each &lt;code&gt;item&lt;/code&gt; has an id and a &lt;code&gt;spec.placement&lt;/code&gt; with the same &lt;code&gt;tagid&lt;/code&gt; the 2.x integration used on &lt;code&gt;imp.banner.tagid&lt;/code&gt;. The exchange accepts the HTTP 200 and returns no bids.&lt;/p&gt;

&lt;p&gt;Nothing in that JSON is syntactically wrong. The failure is semantic: AdCOM never learned what format the placement is.&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;"openrtb"&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;"ver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3.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;"domainspec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"adcom"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"domainver"&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.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;"request"&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;"req-3-0-no-subtype"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"item"&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;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;"item-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;"spec"&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;"placement"&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;"tagid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"banner-atf"&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;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;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;The Placement object names the ad slot. It does not declare banner, video, or audio inventory. Under AdCOM that is an incomplete object, not a minimal valid one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "subtype" means in AdCOM
&lt;/h2&gt;

&lt;p&gt;OpenRTB 2.x kept format fields as siblings on &lt;code&gt;imp&lt;/code&gt;: &lt;code&gt;imp.banner&lt;/code&gt;, &lt;code&gt;imp.video&lt;/code&gt;, &lt;code&gt;imp.audio&lt;/code&gt;, &lt;code&gt;imp.native&lt;/code&gt;. OpenRTB 3.0 folds the inventory description into AdCOM objects under &lt;code&gt;item.spec&lt;/code&gt; on the request side and under &lt;code&gt;bid.media&lt;/code&gt; on the response side.&lt;/p&gt;

&lt;p&gt;AdCOM models several core objects as a discriminated union. A &lt;strong&gt;Placement&lt;/strong&gt; must include at least one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;display&lt;/code&gt; (banner or similar display placement)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;video&lt;/code&gt; (video placement constraints)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;audio&lt;/code&gt; (audio placement constraints)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An &lt;strong&gt;Ad&lt;/strong&gt; on the bid response side has the same rule: at least one of &lt;code&gt;display&lt;/code&gt;, &lt;code&gt;video&lt;/code&gt;, or &lt;code&gt;audio&lt;/code&gt; under &lt;code&gt;media.ad&lt;/code&gt;. A &lt;strong&gt;Native Asset&lt;/strong&gt; needs at least one of &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;video&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt;, or &lt;code&gt;link&lt;/code&gt;. An &lt;strong&gt;AssetFormat&lt;/strong&gt; in a native request template needs at least one of &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;img&lt;/code&gt;, &lt;code&gt;video&lt;/code&gt;, or &lt;code&gt;data&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;These are not optional decoration fields you add when you have time. They are how AdCOM knows which branch of the object you mean. &lt;code&gt;tagid&lt;/code&gt;, &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;w&lt;/code&gt;, and &lt;code&gt;h&lt;/code&gt; are metadata on top of a branch that must exist first.&lt;/p&gt;

&lt;p&gt;The migration trap is copying 2.x shape into 3.0 paths without moving the format block. On 2.x, &lt;code&gt;imp[0].video.mindur&lt;/code&gt; beside &lt;code&gt;imp[0].tagid&lt;/code&gt; was enough. On 3.x that same video block belongs under &lt;code&gt;item[0].spec.placement.video&lt;/code&gt;. Leaving only &lt;code&gt;placement.tagid&lt;/code&gt; is the 3.0 equivalent of an &lt;code&gt;imp&lt;/code&gt; with no &lt;code&gt;banner&lt;/code&gt;, no &lt;code&gt;video&lt;/code&gt;, and no &lt;code&gt;native&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;OpenRTB 2.6 also moved many enumerated lists into AdCOM (&lt;code&gt;plcmt&lt;/code&gt;, &lt;code&gt;pos&lt;/code&gt;, content taxonomies). That work is about value lists. The subtype rules are about object shape: which child object carries the format contract. Both matter, and they fail in different ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  How you catch it
&lt;/h2&gt;

&lt;p&gt;Schema validation that only checks required keys on Placement will miss this. The object has an id path. JSON parsers are happy.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://rtblint.org" rel="noopener noreferrer"&gt;RTBlint&lt;/a&gt;, an open source OpenRTB linter. On the fixture above it reports:&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; 3.0 bid-request.json
&lt;span class="go"&gt;FAILED (OpenRTB 3.0 bid request): 1 error(s), 0 warning(s).
- [error] openrtb.request.item[0].spec.placement: A Placement needs at
  least one of display, video, or audio.
  (adcom.placement.subtype_required)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same pattern fires on responses. A bid that prices against &lt;code&gt;item-1&lt;/code&gt; but returns an Ad shell with only &lt;code&gt;id&lt;/code&gt; fails &lt;a href="https://rtblint.org/docs/rule-reference/adcom-ad-subtype_required/" rel="noopener noreferrer"&gt;&lt;code&gt;adcom.ad.subtype_required&lt;/code&gt;&lt;/a&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="nl"&gt;"media"&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;"ad"&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;"creative-no-subtype"&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;Native paths have parallel rules. An Asset with only &lt;code&gt;id&lt;/code&gt; and no &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;video&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt;, or &lt;code&gt;link&lt;/code&gt; triggers &lt;a href="https://rtblint.org/docs/rule-reference/adcom-asset-subtype_required/" rel="noopener noreferrer"&gt;&lt;code&gt;adcom.asset.subtype_required&lt;/code&gt;&lt;/a&gt;. A native request template whose AssetFormat lists sizes but no asset type triggers &lt;a href="https://rtblint.org/docs/rule-reference/adcom-assetformat-subtype_required/" rel="noopener noreferrer"&gt;&lt;code&gt;adcom.assetformat.subtype_required&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Paste the payload into the &lt;a href="https://rtblint.org/tester/" rel="noopener noreferrer"&gt;browser tester&lt;/a&gt; if you do not have the CLI handy. The rule id names the AdCOM object and the missing branch, which is faster than diffing against the PDF when you are debugging a 3.0 adapter for the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;Start with the &lt;a href="https://rtblint.org/docs/rule-reference/adcom-placement-subtype_required/" rel="noopener noreferrer"&gt;placement subtype rule reference&lt;/a&gt; for the exact request-side requirement and example invalid payloads.&lt;/p&gt;

&lt;p&gt;If you are mapping 2.6 video fields forward, read how AdCOM 1.0-202607 renamed content objects and extended enum lists on the &lt;a href="https://rtblint.org/blog/adcom-1-0-202607-enums-and-content-rename/" rel="noopener noreferrer"&gt;AdCOM 1.0-202607 enums blog post&lt;/a&gt;. That snapshot is separate from subtype shape, but the same migration project usually touches both.&lt;/p&gt;

&lt;p&gt;For response-side QA, keep the &lt;a href="https://rtblint.org/docs/rule-reference/adcom-ad-subtype_required/" rel="noopener noreferrer"&gt;Ad subtype rule&lt;/a&gt; beside your seatbid fixtures. A valid price and item reference does not rescue an Ad object with no creative branch.&lt;/p&gt;

&lt;p&gt;RTBlint is independent of IAB Tech Lab. The rule ids above cite the published AdCOM object definitions because that is where the requirements live, not because this is an official tool.&lt;/p&gt;

</description>
      <category>openrtb</category>
      <category>adtech</category>
      <category>json</category>
      <category>advertising</category>
    </item>
    <item>
      <title>A TC String in the gpp Parameter Decodes as Header Type 2, Not Type 3</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Wed, 02 Sep 2026 00:34:05 +0000</pubDate>
      <link>https://dev.to/aleksuix/a-tc-string-in-the-gpp-parameter-decodes-as-header-type-2-not-type-3-56mn</link>
      <guid>https://dev.to/aleksuix/a-tc-string-in-the-gpp-parameter-decodes-as-header-type-2-not-type-3-56mn</guid>
      <description>&lt;p&gt;A CMP upgrade leaves one consent string on the page. Engineering copies it into every ad-server macro that mentions privacy. The impression pixel fires. Network tab shows a long base64 blob on &lt;code&gt;gpp&lt;/code&gt;. QA signs off.&lt;/p&gt;

&lt;p&gt;The vendor reading GPP decodes the first six bits of the header and stops. Those bits are &lt;strong&gt;2&lt;/strong&gt;, not &lt;strong&gt;3&lt;/strong&gt;. The string is a valid TCF v2 TC String. It is not a GPP string.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/px?id=1&amp;amp;gpp=CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA&amp;amp;gpp_sid=2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in that URL looks broken if you only check that &lt;code&gt;gpp&lt;/code&gt; is URL-safe base64. The mix-up is structural, not cosmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two parameters, two encodings
&lt;/h2&gt;

&lt;p&gt;IAB privacy signals are not interchangeable keys for the same value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TCF v2&lt;/strong&gt; puts the TC String in &lt;code&gt;gdpr_consent&lt;/code&gt; (or a Floodlight path equivalent) alongside &lt;code&gt;gdpr=1&lt;/code&gt;. The first six bits of the core segment are the TC String version, fixed at &lt;strong&gt;2&lt;/strong&gt; for v2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GPP&lt;/strong&gt; puts a composite string in &lt;code&gt;gpp&lt;/code&gt;, with sections joined by tildes (&lt;code&gt;~&lt;/code&gt;). The first section is a header whose first six bits are fixed at &lt;strong&gt;3&lt;/strong&gt; as the GPP Header field. The next six bits are the GPP specification version (currently &lt;strong&gt;1&lt;/strong&gt;). A well-formed v1 header often starts with the letters &lt;code&gt;DB&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One CMP callback can produce both outputs. They belong on different query keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/px?id=1
  &amp;amp;gdpr=1
  &amp;amp;gdpr_consent=CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA
  &amp;amp;gpp=DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA~1YNN
  &amp;amp;gpp_sid=2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copying &lt;code&gt;gdpr_consent&lt;/code&gt; into &lt;code&gt;gpp&lt;/code&gt; because both are long base64 strings is the usual post-migration bug. It reads thorough in a ticket screenshot. On the wire it tells the GPP decoder it received a TC String in the wrong slot.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://pixellint.org/docs/gpp/" rel="noopener noreferrer"&gt;GPP docs on pixellint.org&lt;/a&gt; walk through header type 3, version 1, and what happens when a TC String lands in &lt;code&gt;gpp&lt;/code&gt;. The &lt;a href="https://pixellint.org/docs/tcf-v2/" rel="noopener noreferrer"&gt;TCF v2 page&lt;/a&gt; covers the mirror mistake: &lt;code&gt;gdpr_consent=1&lt;/code&gt; passes a character check and is not consent because the version bits do not read 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gpp_sid is for
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;gpp_sid&lt;/code&gt; carries the section ID or IDs in force for this transaction. Normally one integer, at most two comma-separated. It is not a table of contents for every section encoded in the string.&lt;/p&gt;

&lt;p&gt;The GPP spec does not require &lt;code&gt;gpp_sid&lt;/code&gt; to match every section inside &lt;code&gt;gpp&lt;/code&gt;. A string can encode more sections than the callee applies on this hop. &lt;code&gt;gpp_sid&lt;/code&gt; says which section applies &lt;strong&gt;now&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;During a US Privacy cutover you may still send deprecated &lt;code&gt;us_privacy&lt;/code&gt; alongside GPP. That is two generations of US signal, not a duplicate of the same key. &lt;a href="https://pixellint.org/docs/duplicate-consent-signals/" rel="noopener noreferrer"&gt;Duplicate consent parameters&lt;/a&gt; on one URL (two &lt;code&gt;gpp&lt;/code&gt; keys, or &lt;code&gt;gpp&lt;/code&gt; plus a second copy of the same TC String in &lt;code&gt;gdpr_consent&lt;/code&gt; with conflicting bits) are a different failure mode than putting one string in the wrong parameter.&lt;/p&gt;

&lt;p&gt;IAB deprecated the standalone &lt;a href="https://pixellint.org/docs/us-privacy-string/" rel="noopener noreferrer"&gt;US Privacy string&lt;/a&gt; on 31 January 2024 in favor of GPP. Keeping &lt;code&gt;us_privacy=1YNN&lt;/code&gt; while you roll out GPP is a documented migration path. Dropping it before every partner reads &lt;code&gt;gpp_sid&lt;/code&gt; looks like you went silent in California.&lt;/p&gt;

&lt;h2&gt;
  
  
  How you catch it
&lt;/h2&gt;

&lt;p&gt;Character-set checks will not save you. Both TC Strings and GPP strings are URL-safe base64. You need to decode the header bits.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://github.com/aleksUIX/pixellint" rel="noopener noreferrer"&gt;Pixellint&lt;/a&gt;, an open source linter for pixels and conversion API payloads. The core privacy pack decodes those first six bits on &lt;code&gt;gpp&lt;/code&gt; and &lt;code&gt;gdpr_consent&lt;/code&gt;. On a TC String pasted into &lt;code&gt;gpp&lt;/code&gt;, it prints:&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;pixellint validate url &lt;span class="s1"&gt;'https://example.com/px?id=1&amp;amp;gpp=CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA&amp;amp;gpp_sid=2'&lt;/span&gt;
&lt;span class="go"&gt;rulepack: core
  error   core.privacy.gpp_header_type
          `gpp` is `CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA`,
          whose header decodes to type 2. The GPP spec fixes the header type
          to 3, so this is not a GPP string.
    fix:  Send the GPP string from the CMP. A TC String belongs in
          `gdpr_consent`, not here.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste the fired pixel URL into the &lt;a href="https://pixellint.org/" rel="noopener noreferrer"&gt;browser validator&lt;/a&gt; if you do not want a local install. Nothing you paste leaves the browser.&lt;/p&gt;

&lt;p&gt;Fix the CMP-to-ad-server mapping, not the alphabet. The TC String is fine. It belongs in &lt;code&gt;gdpr_consent&lt;/code&gt;. The GPP string from the same CMP belongs in &lt;code&gt;gpp&lt;/code&gt; with a matching &lt;code&gt;gpp_sid&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Unexpanded macros are a separate class. A URL that still holds &lt;code&gt;${GPP}&lt;/code&gt; never sent GPP. Empty &lt;code&gt;gpp=&lt;/code&gt; is an unfilled template slot. Core privacy rules skip those so a creative is not scored as a malformed string before serve. After the impression fires, the live request needs a decoded header type 3 and a populated sid. &lt;a href="https://pixellint.org/docs/consent-on-the-wire/" rel="noopener noreferrer"&gt;Consent on the wire&lt;/a&gt; is the request the vendor sees, not the banner state in the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenRTB uses the same strings
&lt;/h2&gt;

&lt;p&gt;OpenRTB 2.6 added &lt;code&gt;regs.gpp&lt;/code&gt; and &lt;code&gt;regs.gpp_sid&lt;/code&gt; alongside the older &lt;code&gt;regs.us_privacy&lt;/code&gt; and &lt;code&gt;user.consent&lt;/code&gt; fields. The encoding rules do not change because the container is JSON. A TC String in &lt;code&gt;regs.gpp&lt;/code&gt; fails the same header-type check. A placeholder in &lt;code&gt;user.consent&lt;/code&gt; fails the same version-bit check as &lt;code&gt;gdpr_consent=1&lt;/code&gt; on a pixel.&lt;/p&gt;

&lt;p&gt;Programmatic and pixel teams often fix one path and leave the other. Bid requests and impression trackers should carry the same consent contract, on the keys each spec names.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pixellint.org/docs/consent/" rel="noopener noreferrer"&gt;Consent and privacy overview&lt;/a&gt; maps TCF, GPP, US Privacy, and vendor-specific dialects.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pixellint.org/docs/gpp/" rel="noopener noreferrer"&gt;GPP header type is 3&lt;/a&gt; covers &lt;code&gt;gpp_sid&lt;/code&gt; shape, migration macros, and why sid does not have to list every section in the string.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pixellint.org/docs/tcf-v2/" rel="noopener noreferrer"&gt;TCF v2 consent strings on pixels&lt;/a&gt; covers &lt;code&gt;gdpr=0&lt;/code&gt; vs &lt;code&gt;gdpr=1&lt;/code&gt;, truncation, and sunset v1 strings.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pixellint.org/packs/core/" rel="noopener noreferrer"&gt;Core rulepack reference&lt;/a&gt; lists every &lt;code&gt;core.privacy.*&lt;/code&gt; finding with spec citations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The short version: GPP header type is 3. TC String version is 2. Same CMP, two parameters, two encodings. A perfect TC String in &lt;code&gt;gpp&lt;/code&gt; is still wrong.&lt;/p&gt;

&lt;p&gt;Pixellint is independent of IAB Tech Lab. The rule ids above cite the GPP and TCF specs because that is where the bit layouts live, not because this is an official IAB tool.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>advertising</category>
      <category>privacy</category>
      <category>gdpr</category>
    </item>
    <item>
      <title>plcmt=5 in the VAST Tag Does Not Make a Linear MP4 a Pause Ad</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Tue, 01 Sep 2026 00:32:28 +0000</pubDate>
      <link>https://dev.to/aleksuix/plcmt5-in-the-vast-tag-does-not-make-a-linear-mp4-a-pause-ad-112l</link>
      <guid>https://dev.to/aleksuix/plcmt5-in-the-vast-tag-does-not-make-a-linear-mp4-a-pause-ad-112l</guid>
      <description>&lt;p&gt;A CTV buyer wins a pause placement. The bid request carried &lt;code&gt;video.plcmt=5&lt;/code&gt;. The VAST response comes back schema-valid, passes the trafficker's eyeball check, and the campaign goes live.&lt;/p&gt;

&lt;p&gt;The viewer hits pause. A thirty-second preroll starts in the content frame.&lt;/p&gt;

&lt;p&gt;Nothing in the trafficking UI threw an error because nothing in the XML was malformed. The bug is a contract mismatch: the tag declared a pause buy and shipped an instream linear creative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two layers that do not auto-align
&lt;/h2&gt;

&lt;p&gt;IAB's CTV Ad Portfolio finalised in July 2026 with a design goal the guidance states plainly: no one should accidentally purchase a pause ad. Buyers opt in through AdCOM's &lt;strong&gt;Plcmt Subtypes (Video)&lt;/strong&gt; list. Values 5 through 9 map to Pause, Screensaver, Overlay, Squeezeback, and In-Scene.&lt;/p&gt;

&lt;p&gt;Five of those six formats deliver through VAST &lt;code&gt;&amp;lt;NonLinearAds&amp;gt;&lt;/code&gt;. Menu ads go through OpenRTB Native and never touch VAST NonLinear.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;plcmt&lt;/code&gt; integer travels in the bid request. Downstream of the auction, the VAST tag often outlives the bid object. SSAI stitchers and measurement vendors may never see the original OpenRTB payload. So the DSP echoes the format context back into the creative as AdCOM signals inside &lt;code&gt;&amp;lt;Extension&amp;gt;&lt;/code&gt; elements.&lt;/p&gt;

&lt;p&gt;On VAST 4.x that looks like one signal per extension:&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;Extensions&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Extension&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"plcmt"&lt;/span&gt; &lt;span class="na"&gt;ext=&lt;/span&gt;&lt;span class="s"&gt;"adcom"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;plcmt&amp;gt;&lt;/span&gt;5&lt;span class="nt"&gt;&amp;lt;/plcmt&amp;gt;&amp;lt;/Extension&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Extension&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"playbackmethod"&lt;/span&gt; &lt;span class="na"&gt;ext=&lt;/span&gt;&lt;span class="s"&gt;"adcom"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;playbackmethod&amp;gt;&lt;/span&gt;9&lt;span class="nt"&gt;&amp;lt;/playbackmethod&amp;gt;&amp;lt;/Extension&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Extensions&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On VAST 2.0, the same five formats can ship through a single back-port container:&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;Extension&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"ctv_ad_portfolio"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;CreativeId&amp;gt;&lt;/span&gt;creative-pause-001&lt;span class="nt"&gt;&amp;lt;/CreativeId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;plcmt&amp;gt;&lt;/span&gt;5&lt;span class="nt"&gt;&amp;lt;/plcmt&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;playbackmethod&amp;gt;&lt;/span&gt;9&lt;span class="nt"&gt;&amp;lt;/playbackmethod&amp;gt;&lt;/span&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;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;"image/jpeg"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"1920"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"1080"&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/pause.jpg]]&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;/MediaFiles&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Extension&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That extension block is not decorative metadata. On 2.0 it is how the portfolio carries the media delivery model the base schema never had. On 4.x the &lt;code&gt;&amp;lt;NonLinear&amp;gt;&lt;/code&gt; node itself can hold &lt;code&gt;&amp;lt;MediaFiles&amp;gt;&lt;/code&gt;, but the AdCOM echo still has to match what actually renders.&lt;/p&gt;

&lt;p&gt;Here is the failure I keep seeing: the extensions declare &lt;code&gt;plcmt=5&lt;/code&gt;, and the only &lt;code&gt;&amp;lt;Creative&amp;gt;&lt;/code&gt; in the ad is &lt;code&gt;&amp;lt;Linear&amp;gt;&lt;/code&gt; with a preroll MP4.&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;Creative&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"creative-001"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Linear&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;Duration&amp;gt;&lt;/span&gt;00:00:30&lt;span class="nt"&gt;&amp;lt;/Duration&amp;gt;&lt;/span&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;"video/mp4"&lt;/span&gt; &lt;span class="na"&gt;delivery=&lt;/span&gt;&lt;span class="s"&gt;"progressive"&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/preroll.mp4]]&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;/MediaFiles&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/Linear&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Creative&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Schema validators that only check element order and types will accept this. The &lt;code&gt;plcmt&lt;/code&gt; value itself is legal anywhere from 1 to 9. Rule &lt;code&gt;VAST-4.4-adcom-plcmt-value&lt;/code&gt; only fires when the integer is outside that range, not when it disagrees with the creative subtree.&lt;/p&gt;

&lt;p&gt;The player does not read &lt;code&gt;plcmt&lt;/code&gt;. It reads &lt;code&gt;&amp;lt;Linear&amp;gt;&lt;/code&gt; versus &lt;code&gt;&amp;lt;NonLinearAds&amp;gt;&lt;/code&gt;. A linear MP4 in a pause slot either plays as instream video over frozen content or gets dropped when the app expects a static overlay. Either way the buy and the delivery diverged silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each field actually commits you to
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;plcmt&lt;/code&gt;&lt;/strong&gt; answers which portfolio format was transacted. Value 5 is Pause. Value 7 is Overlay. Value 8 is Squeezeback. This is an OpenRTB / AdCOM contract surfaced in VAST so downstream hops can see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;&amp;lt;NonLinearAds&amp;gt;&lt;/code&gt;&lt;/strong&gt; answers how the player should render the asset: static image, timed video under NonLinear, SIMID interactive layer, squeezeback geometry. VAST 4.4 expanded NonLinear to accept &lt;code&gt;&amp;lt;MediaFiles&amp;gt;&lt;/code&gt; the way Linear always has, which is why a pause ad can now be a fifteen-second MP4 instead of only a JPEG. The &lt;a href="https://vastlint.org/docs/vast-4-3-to-4-4/" rel="noopener noreferrer"&gt;4.3 to 4.4 migration notes&lt;/a&gt; walk that content-model change field by field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;playbackmethod&lt;/code&gt;&lt;/strong&gt; adds pause-specific semantics on top of plcmt: value 8 is pause with sound on, 9 is pause with sound off. Overlay and squeezeback reuse the older instream playbackmethod values. Mixing pause plcmt with instream playbackmethod is another silent mismatch buyers filter on even when the XML validates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;attr&lt;/code&gt;&lt;/strong&gt; (Creative Attributes 21, 22, 23) separates static, cinemagraph, and full-motion delivery. A publisher blocking full-motion pause inventory sets &lt;code&gt;battr&lt;/code&gt; accordingly. Shipping &lt;code&gt;attr=23&lt;/code&gt; on a static JPEG pause creative mislabels motion expectations the other way.&lt;/p&gt;

&lt;p&gt;On VAST 2.0 with multiple &lt;code&gt;&amp;lt;Creative&amp;gt;&lt;/code&gt; elements, each &lt;code&gt;&amp;lt;Extension type="ctv_ad_portfolio"&amp;gt;&lt;/code&gt; needs a &lt;code&gt;&amp;lt;CreativeId&amp;gt;&lt;/code&gt; that matches a real creative id. Without it, the extension binds to whichever creative the platform picks. Rule &lt;code&gt;VAST-2.0-ctv-portfolio-creative-id-required&lt;/code&gt; catches the two-creative, no-id case. Rule &lt;code&gt;VAST-2.0-ctv-portfolio-mediafiles-required&lt;/code&gt; catches an extension that declares plcmt but carries no &lt;code&gt;&amp;lt;MediaFiles&amp;gt;&lt;/code&gt; and no native NonLinear resource to render.&lt;/p&gt;

&lt;p&gt;None of those rules replace the structural check: does this tag actually contain a NonLinear creative for a NonLinear buy?&lt;/p&gt;

&lt;h2&gt;
  
  
  How I catch it before the player does
&lt;/h2&gt;

&lt;p&gt;I start with the live tag URL in the &lt;a href="https://vastlint.org/tester/" rel="noopener noreferrer"&gt;VAST tag tester&lt;/a&gt;. Paste the ad server's response URL or the XML itself. The tester fetches wrappers, shows which creative type loaded, surfaces tracking URLs, and previews the media file the player would pick. On a pause buy I expect NonLinear preview behavior, not a linear progress bar counting to thirty seconds.&lt;/p&gt;

&lt;p&gt;If the tag is wrapped, I run the same URL through the &lt;a href="https://vastlint.org/inspect/" rel="noopener noreferrer"&gt;VAST inspector&lt;/a&gt;. Portfolio tags often chain through a DSP wrapper before the InLine with the actual creative. The inspector walks each hop and shows where Linear handoff happens versus where the wrapper still carries only AdCOM extensions with no renderable NonLinear node.&lt;/p&gt;

&lt;p&gt;After that I run &lt;code&gt;vastlint check&lt;/code&gt; in CI on the resolved InLine. The AdCOM signal rules confirm integers are in range. The 2.0 portfolio rules confirm extension binding and media presence. I still grep the XML for &lt;code&gt;&amp;lt;Linear&amp;gt;&lt;/code&gt; under a creative that should be portfolio inventory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the spec splits 2.0 and 4.x
&lt;/h2&gt;

&lt;p&gt;You do not need &lt;code&gt;version="4.4"&lt;/code&gt; to use portfolio NonLinear content. IAB's own examples ship on &lt;code&gt;version="4.2"&lt;/code&gt; with the expanded NonLinear model. The &lt;a href="https://vastlint.org/docs/ctv-ad-portfolio/" rel="noopener noreferrer"&gt;CTV Ad Portfolio overview&lt;/a&gt; maps each format to its OpenRTB object and VAST response shape.&lt;/p&gt;

&lt;p&gt;Legacy stacks that only parse VAST 2.0 rely on the &lt;a href="https://vastlint.org/blog/ctv-ad-portfolio-vast-2-0-extensions/" rel="noopener noreferrer"&gt;ctv_ad_portfolio extension path&lt;/a&gt;: AdCOM signals, MediaFiles, and Duration live inside &lt;code&gt;&amp;lt;Extension type="ctv_ad_portfolio"&amp;gt;&lt;/code&gt; while the creative may still expose a minimal NonLinear StaticResource for players that ignore extensions.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://vastlint.org/blog/ctv-ad-portfolio-signaling-vast-validation/" rel="noopener noreferrer"&gt;signaling versus validation split&lt;/a&gt; matters here: the July 2026 signaling guidance is final; &lt;code&gt;vast_4.4.xsd&lt;/code&gt; is still draft. Value-level checks from the final guidance apply regardless of version declaration. Structural checks on the NonLinear content model apply on any 4.x document using those nodes. Read the &lt;a href="https://vastlint.org/docs/vast-4-4/" rel="noopener noreferrer"&gt;VAST 4.4 draft index&lt;/a&gt; when you need the rule ids tied to each element.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;plcmt=5&lt;/code&gt; in an extension records that the buy was a pause placement. It does not convert a Linear preroll into a pause ad. The creative has to live under &lt;code&gt;&amp;lt;NonLinearAds&amp;gt;&lt;/code&gt; (or the 2.0 extension has to carry renderable &lt;code&gt;&amp;lt;MediaFiles&amp;gt;&lt;/code&gt; bound to the right &lt;code&gt;&amp;lt;CreativeId&amp;gt;&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Validate the signal integers. Then validate the shape. The tester shows what renders; the inspector shows where the wrapper hid the InLine; the linter catches binding and range defects the player never reports.&lt;/p&gt;

&lt;p&gt;I maintain vastlint independently of IAB Tech Lab. The rule ids above come from the published CTV Ad Portfolio guidance and the VAST 4.4 draft schema notes in the repo, not from a vendor QA checklist.&lt;/p&gt;

</description>
      <category>vast</category>
      <category>ctv</category>
      <category>adtech</category>
      <category>xml</category>
    </item>
    <item>
      <title>MCP validate_bid_request Defaults to profile spec. Google AB Needs at: 3 and billing_id.</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Mon, 31 Aug 2026 00:17:50 +0000</pubDate>
      <link>https://dev.to/aleksuix/mcp-validatebidrequest-defaults-to-profile-spec-google-ab-needs-at-3-and-billingid-1fm6</link>
      <guid>https://dev.to/aleksuix/mcp-validatebidrequest-defaults-to-profile-spec-google-ab-needs-at-3-and-billingid-1fm6</guid>
      <description>&lt;p&gt;A coding agent wiring Google Authorized Buyers integration calls &lt;code&gt;validate_bid_request&lt;/code&gt; on the RTBlint MCP server. The tool returns &lt;code&gt;"valid": true&lt;/code&gt;. The payload still fails on Google's side.&lt;/p&gt;

&lt;p&gt;The request is not malformed OpenRTB. It is the wrong contract for the exchange you are targeting:&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;"at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&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;"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;"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;"banner"&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;"w"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"h"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;250&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;at: 3&lt;/code&gt; is FIXED_PRICE in &lt;a href="https://developers.google.com/authorized-buyers/rtb/openrtb-guide" rel="noopener noreferrer"&gt;Google's Authorized Buyers OpenRTB guide&lt;/a&gt;. The IAB spec's auction type value set is &lt;code&gt;{1, 2}&lt;/code&gt; plus vendor values from 500 upward. Three sits in the gap. Without Google's profile declared, a linter correctly flags it as illegal.&lt;/p&gt;

&lt;p&gt;The imp is also missing &lt;code&gt;ext.billing_id&lt;/code&gt;. Google documents that array as required: the billing IDs a winning bid may attribute the impression to. The specification treats it as an optional extension. A spec-only validator has nothing to complain about.&lt;/p&gt;

&lt;h2&gt;
  
  
  profile is not dialect
&lt;/h2&gt;

&lt;p&gt;MCP exposes both knobs on &lt;code&gt;validate_bid_request&lt;/code&gt; and &lt;code&gt;validate_bid_response&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dialect&lt;/code&gt;: &lt;code&gt;spec-json&lt;/code&gt; (integer flags like &lt;code&gt;secure: 1&lt;/code&gt;) vs &lt;code&gt;proto-json&lt;/code&gt; (boolean flags for gRPC protobuf JSON).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;profile&lt;/code&gt;: &lt;code&gt;spec&lt;/code&gt; (default), &lt;code&gt;google-ab&lt;/code&gt;, &lt;code&gt;prebid-server&lt;/code&gt;, &lt;code&gt;xandr&lt;/code&gt;, or &lt;code&gt;magnite&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dialect is encoding. Profile is the extra protocol an exchange publishes on top of the same JSON shape. Google Authorized Buyers still uses integer flags. It still needs &lt;code&gt;profile=google-ab&lt;/code&gt; because the auction type table and required extensions differ.&lt;/p&gt;

&lt;p&gt;An agent that learned OpenRTB from spec PDFs and training snapshots will produce &lt;code&gt;at: 1&lt;/code&gt; or &lt;code&gt;at: 2&lt;/code&gt; and skip &lt;code&gt;billing_id&lt;/code&gt;. An agent that learned from Google's docs will emit &lt;code&gt;at: 3&lt;/code&gt; and expect billing fields. Point both at MCP with defaults and only the second payload looks broken, even though only the first is deliverable to Google without fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the default tool call actually checks
&lt;/h2&gt;

&lt;p&gt;I maintain &lt;a href="https://rtblint.org" rel="noopener noreferrer"&gt;RTBlint&lt;/a&gt;, an open source OpenRTB linter. The &lt;a href="https://rtblint.org/docs/mcp/" rel="noopener noreferrer"&gt;hosted MCP server&lt;/a&gt; wraps the same core as the CLI and the &lt;a href="https://rtblint.org/tester/" rel="noopener noreferrer"&gt;browser tester&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Against the default profile:&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 bid.json
&lt;span class="go"&gt;FAILED (OpenRTB 2.6-202606 bid request): 1 error(s), 0 warning(s).
- [error] at: at is 3, which is not among the allowed values {1, 2}.
  (openrtb.value.invalid) · spec 3.2.1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No mention of &lt;code&gt;billing_id&lt;/code&gt;, because the spec does not require it.&lt;/p&gt;

&lt;p&gt;Pass the profile Google documents:&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;--profile&lt;/span&gt; google-ab bid-with-at3.json
&lt;span class="go"&gt;FAILED (OpenRTB 2.6-202606 bid request): 1 error(s), 0 warning(s).
- [error] imp[0].ext.billing_id: Google Authorized Buyers requires
  imp.ext.billing_id. (openrtb.profile.field_required) · profile google-ab

&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;--profile&lt;/span&gt; google-ab google-ready.json
&lt;span class="go"&gt;OK (OpenRTB 2.6-202606 bid request): no issues found.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same JSON. Three different verdicts depending on whether you declared the exchange protocol.&lt;/p&gt;

&lt;p&gt;Over MCP the shape is identical. The agent passes &lt;code&gt;profile&lt;/code&gt; in the tool arguments:&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;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"profile"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"google-ab"&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;The &lt;a href="https://rtblint.org/docs/openrtb-validator-mcp/" rel="noopener noreferrer"&gt;OpenRTB MCP validator docs&lt;/a&gt; and the full &lt;a href="https://rtblint.org/docs/mcp/" rel="noopener noreferrer"&gt;MCP tool reference&lt;/a&gt; list every argument. The server card at &lt;a href="https://rtblint.org/.well-known/mcp/server-card.json" rel="noopener noreferrer"&gt;/.well-known/mcp/server-card.json&lt;/a&gt; enumerates the profile enum for clients that auto-wire tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this shows up on agent loops
&lt;/h2&gt;

&lt;p&gt;Registering an MCP server is not validating against the exchange that will receive the traffic. &lt;code&gt;get_adcp_capabilities&lt;/code&gt; tells an AdCP pipeline that RTBlint computes &lt;code&gt;openrtb_error_count&lt;/code&gt; and related metrics. It does not pin &lt;code&gt;google-ab&lt;/code&gt;, &lt;code&gt;prebid-server&lt;/code&gt;, or any other profile. Discovery answers what the tool can do, not which protocol variant your deployment targets.&lt;/p&gt;

&lt;p&gt;That gap matters in agentic buying stacks where the same MCP host validates bid requests coming out of an AdCP media buy. The &lt;a href="https://rtblint.org/guides/adcp-to-openrtb/" rel="noopener noreferrer"&gt;AdCP to OpenRTB mapping guide&lt;/a&gt; is where field-level translation lives. Profile selection is the step after mapping: once the object looks like OpenRTB, you still have to name the exchange extras.&lt;/p&gt;

&lt;p&gt;Prebid Server is the same class of bug under a different profile. Each imp must name a bidder or stored request, and several Prebid-only extensions become required. Xandr expects &lt;code&gt;ext.appnexus.seller_member_id&lt;/code&gt;. Magnite expects &lt;code&gt;ext.rp&lt;/code&gt; identity fields. Default &lt;code&gt;profile=spec&lt;/code&gt; validates none of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I wire it in practice
&lt;/h2&gt;

&lt;p&gt;For a human review pass, paste request and response into the &lt;a href="https://rtblint.org/tester/" rel="noopener noreferrer"&gt;tester&lt;/a&gt; with the profile dropdown set to Google AB. The UI surfaces the same rule ids as MCP.&lt;/p&gt;

&lt;p&gt;For CI or a local agent loop:&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;--profile&lt;/span&gt; google-ab &lt;span class="nt"&gt;--type&lt;/span&gt; request req.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://rtblint.org/docs/cli/" rel="noopener noreferrer"&gt;CLI docs&lt;/a&gt; cover batch mode, version pinning, and dialect flags. The &lt;a href="https://rtblint.org/docs/openrtb/" rel="noopener noreferrer"&gt;OpenRTB overview&lt;/a&gt; ties profiles, dialects, and dated spec snapshots together.&lt;/p&gt;

&lt;p&gt;If payloads must not leave the network, run &lt;code&gt;rtblint-mcp&lt;/code&gt; over stdio with the same tool names. The hosted endpoint may store stripped samples under its &lt;a href="https://rtblint.org/privacy/" rel="noopener noreferrer"&gt;privacy policy&lt;/a&gt;; local stdio does not.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;validate_bid_request&lt;/code&gt; without &lt;code&gt;profile&lt;/code&gt; checks the IAB specification, not Google Authorized Buyers, not Prebid Server, not Xandr, not Magnite.&lt;/p&gt;

&lt;p&gt;For Google AB, pass &lt;code&gt;profile=google-ab&lt;/code&gt; before you trust &lt;code&gt;valid: true&lt;/code&gt;. FIXED_PRICE (&lt;code&gt;at: 3&lt;/code&gt;) becomes legal. Missing &lt;code&gt;Imp.ext.billing_id&lt;/code&gt; becomes an error instead of silence.&lt;/p&gt;

&lt;p&gt;An MCP tool registration proves the agent can call a validator. It does not prove the validator ran against the exchange that will answer the auction.&lt;/p&gt;

&lt;p&gt;RTBlint is independent of Google and IAB Tech Lab. The rule ids above cite the public spec and Google's OpenRTB guide because that is where the requirements live.&lt;/p&gt;

</description>
      <category>openrtb</category>
      <category>api</category>
      <category>adtech</category>
      <category>grpc</category>
    </item>
    <item>
      <title>Reddit CAPI v3 Wants Milliseconds. Meta's Seconds Helper Lands in 1970.</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Sun, 30 Aug 2026 18:13:06 +0000</pubDate>
      <link>https://dev.to/aleksuix/reddit-capi-v3-wants-milliseconds-metas-seconds-helper-lands-in-1970-1gdo</link>
      <guid>https://dev.to/aleksuix/reddit-capi-v3-wants-milliseconds-metas-seconds-helper-lands-in-1970-1gdo</guid>
      <description>&lt;p&gt;A purchase handler posts to Reddit's Conversions API. The request returns 200. Reddit Ads stays empty, or the conversion sits in 1970.&lt;/p&gt;

&lt;p&gt;The access token is fine. The Pixel ID in the path is fine. The payload is the Meta helper with the host changed.&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;"data"&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;"events"&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;span class="nl"&gt;"event_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1770000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"action_source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"website"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&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;"tracking_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PURCHASE"&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;"user"&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;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"buyer@example.com"&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;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 JSON is legal. It is also the wrong clock and the wrong &lt;code&gt;action_source&lt;/code&gt; enum.&lt;/p&gt;

&lt;h2&gt;
  
  
  event_at is milliseconds, not Meta seconds
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://ads-api-reddit.netlify.app/docs/v3/guides/programs/capi/migration" rel="noopener noreferrer"&gt;Reddit CAPI v3 documents &lt;code&gt;event_at&lt;/code&gt; as Unix time in milliseconds&lt;/a&gt;. Thirteen digits. &lt;code&gt;Date.now()&lt;/code&gt; is already that unit.&lt;/p&gt;

&lt;p&gt;Meta's Conversions API wants the inverse. &lt;a href="https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event" rel="noopener noreferrer"&gt;&lt;code&gt;event_time&lt;/code&gt; is seconds&lt;/a&gt;, ten digits, at most seven days old. The Meta fix is &lt;code&gt;Math.floor(Date.now() / 1000)&lt;/code&gt;. Copy that line into a Reddit worker and &lt;code&gt;1770000000&lt;/code&gt; is January 1970 as milliseconds.&lt;/p&gt;

&lt;p&gt;Reddit will still accept the POST. A 1970 conversion is older than any attribution window you care about. The status line does not mention the unit.&lt;/p&gt;

&lt;p&gt;v2 used an ISO &lt;code&gt;event_at&lt;/code&gt;. v3 does not. A string timestamp that used to work is a type error on the new envelope. Do not mix the two.&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;// Wrong on Reddit CAPI v3 (right on Meta)&lt;/span&gt;
&lt;span class="nx"&gt;event_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Right on Reddit CAPI v3 (wrong on Meta)&lt;/span&gt;
&lt;span class="nx"&gt;event_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LinkedIn's &lt;code&gt;conversionHappenedAt&lt;/code&gt; is also thirteen digits. Pinterest's &lt;code&gt;event_time&lt;/code&gt; is ten, like Meta. There is no shared helper that is correct for all four without a branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  action_source is WEBSITE, not website
&lt;/h2&gt;

&lt;p&gt;v3 makes &lt;code&gt;action_source&lt;/code&gt; required. Reddit documents &lt;code&gt;WEBSITE&lt;/code&gt;, &lt;code&gt;APP&lt;/code&gt;, &lt;code&gt;PHYSICAL_STORE&lt;/code&gt;, and &lt;code&gt;OTHER&lt;/code&gt;. Meta writes &lt;code&gt;website&lt;/code&gt;. Pinterest writes &lt;code&gt;web&lt;/code&gt;. Snap writes &lt;code&gt;WEB&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="nl"&gt;"action_source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"website"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is Meta's spelling. On Reddit it is not in the enum. A shared &lt;code&gt;action_source&lt;/code&gt; column without a per-vendor map is a silent invalid on at least one hop.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;type.tracking_type&lt;/code&gt; is the event name, in &lt;code&gt;UPPER_SNAKE_CASE&lt;/code&gt;: &lt;code&gt;PAGE_VISIT&lt;/code&gt;, &lt;code&gt;VIEW_CONTENT&lt;/code&gt;, &lt;code&gt;SEARCH&lt;/code&gt;, &lt;code&gt;ADD_TO_CART&lt;/code&gt;, &lt;code&gt;ADD_TO_WISHLIST&lt;/code&gt;, &lt;code&gt;PURCHASE&lt;/code&gt;, &lt;code&gt;LEAD&lt;/code&gt;, &lt;code&gt;SIGN_UP&lt;/code&gt;, or &lt;code&gt;CUSTOM&lt;/code&gt; plus &lt;code&gt;custom_event_name&lt;/code&gt;. Meta's &lt;code&gt;Purchase&lt;/code&gt; and Pinterest's &lt;code&gt;checkout&lt;/code&gt; are different strings. &lt;code&gt;Purchase&lt;/code&gt; copied into &lt;code&gt;tracking_type&lt;/code&gt; is a custom name here, not a purchase, unless you also send &lt;code&gt;CUSTOM&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The pixel on &lt;code&gt;alb.reddit.com/rp.gif&lt;/code&gt; is a different pipe. &lt;code&gt;id&lt;/code&gt; is the advertiser ID. &lt;code&gt;event&lt;/code&gt; is a name like &lt;code&gt;PageVisit&lt;/code&gt; or &lt;code&gt;Purchase&lt;/code&gt;. That image GET is not the CAPI JSON. Dedup between them is weaker than Meta's &lt;code&gt;event_id&lt;/code&gt; pair. Mint a stable id for retries of the same Reddit CAPI event so a 500 does not become two &lt;code&gt;PURCHASE&lt;/code&gt; rows. Replays keep the original &lt;code&gt;event_at&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hash email if you want. Do not hash the IP.
&lt;/h2&gt;

&lt;p&gt;Reddit CAPI v3 allows email and phone raw or hashed. That is not permission to send a raw address to Meta, Pinterest, or TikTok. Convert at the edge.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;user.ip_address&lt;/code&gt; and &lt;code&gt;user.user_agent&lt;/code&gt; stay in the clear. A SHA-256 looking digest in those fields is an unmatchable event. The "hash everything in &lt;code&gt;user_*&lt;/code&gt;" helper that made Meta's &lt;code&gt;em&lt;/code&gt; valid is how Reddit matching dies.&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="nl"&gt;"user"&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;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"buyer@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ip_address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"17af1cf3d1b5332c53349fc789abdc853bbeea7ed33eff727ff794ab741ccac9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user_agent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mozilla/5.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;&lt;code&gt;metadata.currency&lt;/code&gt;, when present, is an ISO 4217 code (&lt;code&gt;USD&lt;/code&gt;), not &lt;code&gt;$&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The path is &lt;code&gt;/api/v3/pixels/{pixel_id}/conversion_events&lt;/code&gt; on &lt;code&gt;ads-api.reddit.com&lt;/code&gt;. The Pixel ID lives in the path, not in a JSON field named &lt;code&gt;pixel_id&lt;/code&gt; next to Meta's Graph token. Pointing a Meta client at that host with a &lt;code&gt;data[]&lt;/code&gt; of &lt;code&gt;event_name&lt;/code&gt; / &lt;code&gt;event_time&lt;/code&gt; objects is a different miss from the clock bug. The envelopes do not overlap.&lt;/p&gt;

&lt;h2&gt;
  
  
  How you catch it before Ads Manager is empty
&lt;/h2&gt;

&lt;p&gt;I maintain &lt;a href="https://github.com/aleksUIX/pixellint" rel="noopener noreferrer"&gt;Pixellint&lt;/a&gt;, an open source linter for pixels and conversion API payloads. The Reddit CAPI pack (&lt;code&gt;vendor/reddit-conversions-api&lt;/code&gt;) contracts &lt;code&gt;data.events[]&lt;/code&gt; against Reddit's v3 parameter docs. This is what it prints on the seconds-and-&lt;code&gt;website&lt;/code&gt; payload above:&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;pixellint validate json @reddit-capi.json
&lt;span class="go"&gt;rulepack: vendor/reddit-conversions-api (vendor: reddit)
  error   vendor.reddit-conversions-api.body.event_at.invalid
          `event_at` must be at least 13 digits, but `1770000000` has 10.
          Reddit CAPI v3 documents `event_at` as Unix time in milliseconds.
          A 10-digit value is seconds and lands in 1970.
    fix:  Send milliseconds: a JavaScript `Date.now()` is already in the
          right unit.
  error   vendor.reddit-conversions-api.body.action_source.invalid
          `action_source` is `website`, which is not one of the documented
          values: WEBSITE, APP, PHYSICAL_STORE, OTHER.
    fix:  Send `WEBSITE`, `APP`, `PHYSICAL_STORE`, or `OTHER`.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hashed IP prints &lt;code&gt;vendor.reddit-conversions-api.body.hashed_plaintext_field&lt;/code&gt;. A Reddit Pixel URL with no advertiser id prints &lt;code&gt;vendor.reddit.param.id.missing&lt;/code&gt; on &lt;code&gt;vendor/reddit&lt;/code&gt;, which is a different pack.&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;pixellint
&lt;span class="c"&gt;# or: npm install pixellint&lt;/span&gt;
pixellint validate json @reddit-capi.json &lt;span class="nt"&gt;--rulepack&lt;/span&gt; vendor/reddit-conversions-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste the body into the &lt;a href="https://pixellint.org/" rel="noopener noreferrer"&gt;playground&lt;/a&gt; if you do not want a local install. Artifacts you test may be stored; see &lt;a href="https://pixellint.org/privacy/" rel="noopener noreferrer"&gt;privacy&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://pixellint.org/docs/reddit-pixel-and-capi/" rel="noopener noreferrer"&gt;Reddit CAPI playbook&lt;/a&gt; is how both pipes share a clock and an event name. The &lt;a href="https://pixellint.org/packs/reddit-conversions-api/" rel="noopener noreferrer"&gt;Reddit CAPI pack page&lt;/a&gt; is the field table. The &lt;a href="https://pixellint.org/packs/reddit/" rel="noopener noreferrer"&gt;Reddit Pixel pack&lt;/a&gt; is the image GET.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;A conversion API is a POST the browser never made. &lt;a href="https://pixellint.org/docs/what-is-a-conversion-api/" rel="noopener noreferrer"&gt;What is a conversion API&lt;/a&gt; is the envelope, the clock, and the match keys across vendors. &lt;a href="https://pixellint.org/docs/one-capi-json-cannot-serve-every-vendor/" rel="noopener noreferrer"&gt;One conversion API JSON cannot serve every vendor&lt;/a&gt; is the adapter argument: store one UTC instant, convert at the edge.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pixellint.org/docs/timestamp-units/" rel="noopener noreferrer"&gt;Event time units&lt;/a&gt; maps Meta seconds, Reddit and LinkedIn milliseconds, GA4 microseconds, and TikTok ISO 8601 so you are not guessing from one vendor's sample. Pinterest is seconds again, with lowercase &lt;code&gt;web&lt;/code&gt; and lowercase standard events (&lt;code&gt;checkout&lt;/code&gt;, not &lt;code&gt;Checkout&lt;/code&gt;); that contrast lives in the &lt;a href="https://pixellint.org/docs/pinterest-tag-and-capi/" rel="noopener noreferrer"&gt;Pinterest standard events playbook&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Reddit CAPI v3 &lt;code&gt;event_at&lt;/code&gt; is &lt;code&gt;Date.now()&lt;/code&gt; in milliseconds, not Meta's seconds. &lt;code&gt;action_source&lt;/code&gt; is &lt;code&gt;WEBSITE&lt;/code&gt;. &lt;code&gt;tracking_type&lt;/code&gt; is &lt;code&gt;PURCHASE&lt;/code&gt;, not &lt;code&gt;Purchase&lt;/code&gt;. Email may be raw or hashed. IP and user-agent must not be digests. The Pixel ID is in the path. Do not POST Graph-shaped JSON to &lt;code&gt;ads-api.reddit.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Pixellint is independent of Reddit. The rule ids above cite Reddit's Conversions API docs because that is where the requirements live, not because this is an official tool.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>reddit</category>
      <category>analytics</category>
      <category>api</category>
    </item>
    <item>
      <title>A Companion With No Width Is Not a 300x250. The Player Cannot Match It.</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Sat, 29 Aug 2026 19:00:33 +0000</pubDate>
      <link>https://dev.to/aleksuix/a-companion-with-no-width-is-not-a-300x250-the-player-cannot-match-it-45h7</link>
      <guid>https://dev.to/aleksuix/a-companion-with-no-width-is-not-a-300x250-the-player-cannot-match-it-45h7</guid>
      <description>&lt;p&gt;A publisher page exposes a 300x250 slot. The VAST player walks &lt;code&gt;&amp;lt;Companion&amp;gt;&lt;/code&gt; elements and keeps the first whose &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; match that slot. A Companion with a JPEG and no dimensions is not a 300x250. It is a banner the player cannot name.&lt;/p&gt;

&lt;p&gt;Trafficking often pastes &lt;code&gt;&amp;lt;StaticResource&amp;gt;&lt;/code&gt; and skips the attributes. The video plays. The slot stays empty. If &lt;code&gt;CompanionAds&lt;/code&gt; is &lt;code&gt;required="all"&lt;/code&gt;, some players also drop the linear.&lt;/p&gt;

&lt;p&gt;That is not a CDN miss. The image URL can 200. The match never ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  Width and height are how the slot matches
&lt;/h2&gt;

&lt;p&gt;VAST requires &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; on every &lt;code&gt;&amp;lt;Companion&amp;gt;&lt;/code&gt;. The spec does not default them from the image file. The player is not required to HEAD the JPEG to learn 300 by 250. If the attributes are missing, the match set is empty.&lt;/p&gt;

&lt;p&gt;IAB display still clusters on a small set of sizes. 300x250 is the default mid-page rectangle. 728x90 is the desktop leaderboard. 160x600 is the wide skyscraper. 320x50 is the mobile banner. One size is not enough if you traffic both desktop and mobile.&lt;/p&gt;

&lt;p&gt;A copyable block that a player can actually match:&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;CompanionAds&lt;/span&gt; &lt;span class="na"&gt;required=&lt;/span&gt;&lt;span class="s"&gt;"none"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Companion&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"300"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"250"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;StaticResource&lt;/span&gt; &lt;span class="na"&gt;creativeType=&lt;/span&gt;&lt;span class="s"&gt;"image/jpeg"&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/300x250.jpg]]&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/StaticResource&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/Companion&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Companion&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"728"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"90"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;StaticResource&lt;/span&gt; &lt;span class="na"&gt;creativeType=&lt;/span&gt;&lt;span class="s"&gt;"image/jpeg"&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/728x90.jpg]]&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/StaticResource&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/Companion&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/CompanionAds&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The broken form looks present in a text editor:&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;CompanionAds&lt;/span&gt; &lt;span class="na"&gt;required=&lt;/span&gt;&lt;span class="s"&gt;"all"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Companion&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;StaticResource&lt;/span&gt; &lt;span class="na"&gt;creativeType=&lt;/span&gt;&lt;span class="s"&gt;"image/jpeg"&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/banner.jpg]]&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/StaticResource&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/Companion&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/CompanionAds&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a resource. There is no size. There is no slot.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://vastlint.org" rel="noopener noreferrer"&gt;vastlint&lt;/a&gt;, an open source VAST linter. On that second snippet it reports &lt;a href="https://vastlint.org/docs/rules/VAST-2.0-companion-dimensions/" rel="noopener noreferrer"&gt;&lt;code&gt;VAST-2.0-companion-dimensions&lt;/code&gt;&lt;/a&gt;. Severity is warning, because the XSD leaves the attributes optional while the prose requires them. The player still has nothing to match.&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 companion.xml
&lt;span class="go"&gt;companion.xml  VAST 4.2
  warning  Companion is missing width or height. Required by spec, optional in XSD.
           VAST-2.0-companion-dimensions
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--fail-on-warning&lt;/code&gt; is how you make that a launch gate.&lt;/p&gt;

&lt;p&gt;The size list, and why one rendition is not enough, is on &lt;a href="https://vastlint.org/guides/vast-companion-sizes/" rel="noopener noreferrer"&gt;VAST companion sizes&lt;/a&gt;. The XML checklist is &lt;a href="https://vastlint.org/blog/how-to-validate-companion-ads/" rel="noopener noreferrer"&gt;How to Validate Companion Ads&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  required=all is a kill switch for the linear
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;required&lt;/code&gt; on &lt;code&gt;&amp;lt;CompanionAds&amp;gt;&lt;/code&gt; is &lt;code&gt;all&lt;/code&gt;, &lt;code&gt;any&lt;/code&gt;, or &lt;code&gt;none&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;none&lt;/code&gt; keeps the video if the banner misses. &lt;code&gt;any&lt;/code&gt; wants at least one companion to render. &lt;code&gt;all&lt;/code&gt; means every companion in the block has to be usable, and a miss can take down the break.&lt;/p&gt;

&lt;p&gt;An empty Companion plus &lt;code&gt;required="all"&lt;/code&gt; is how a missing banner becomes a missing ad. That is the failure mode on &lt;a href="https://vastlint.org/guides/vast-companion-ads-not-showing/" rel="noopener noreferrer"&gt;companion ads not showing&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Most CTV runtimes ignore companions. Do not set &lt;code&gt;required="all"&lt;/code&gt; for a CTV-only tag unless the platform documents companion support.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrappers can drop the banner you QA'd
&lt;/h2&gt;

&lt;p&gt;CompanionAds on the inline you pasted is not proof the player received it. A wrapper hop can omit companions. &lt;code&gt;followCompanions&lt;/code&gt; on a Wrapper is the flag that says whether to keep them. If the live chain drops the block, the page slot stays blank and your fixture still looks fine. QA the document at the bottom of the chain, not the first URL in the ticket.&lt;/p&gt;

&lt;p&gt;Paste checks the document you have. A live tag URL still has to unwrap. Use the &lt;a href="https://vastlint.org/tester/" rel="noopener noreferrer"&gt;VAST tester&lt;/a&gt; for the fetch and preview. Use the &lt;a href="https://vastlint.org/inspect/" rel="noopener noreferrer"&gt;inspector&lt;/a&gt; when the player printed 303 or the banner vanished after a hop.&lt;/p&gt;

&lt;p&gt;The OpenRTB side can be clean the whole time. &lt;code&gt;imp.video&lt;/code&gt; and &lt;code&gt;bid.adm&lt;/code&gt; can pass a bid-stream linter while the VAST inside is a wrapper that returns no ads, or a companion with no width. That split is &lt;a href="https://rtblint.org/blog/video-bid-is-not-vast-proof/" rel="noopener noreferrer"&gt;A spec-valid video bid can still fill with empty VAST&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  CompanionClickThrough is a click tracker
&lt;/h2&gt;

&lt;p&gt;The banner has its own click URL. It should 302 until the landing page. It should not be the Impression pixel with a different cache buster. If you are debugging the collector rather than the XML, &lt;a href="https://pixellint.org/docs/impression-vs-click-trackers/" rel="noopener noreferrer"&gt;impression pixels vs click trackers&lt;/a&gt; is the slot contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to run
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Paste resolved XML. Catch missing &lt;code&gt;width&lt;/code&gt;/&lt;code&gt;height&lt;/code&gt; and empty Companion before you blame the page.&lt;/li&gt;
&lt;li&gt;Fetch the live URL in the &lt;a href="https://vastlint.org/tester/" rel="noopener noreferrer"&gt;tester&lt;/a&gt;. Confirm the companions that survived the chain.&lt;/li&gt;
&lt;li&gt;If a hop ate them, walk the chain in the &lt;a href="https://vastlint.org/inspect/" rel="noopener noreferrer"&gt;inspector&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Ship more than one IAB size. Then look at the page slot. Size mismatch after valid XML is trafficking, not schema.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>vast</category>
      <category>xml</category>
      <category>advertising</category>
      <category>ctv</category>
    </item>
    <item>
      <title>LIFECYCLE_DSP_BID_RESPONSE Can Ship Without bid_response. The Agent Has Nothing to Shade.</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Fri, 28 Aug 2026 23:21:15 +0000</pubDate>
      <link>https://dev.to/aleksuix/lifecycledspbidresponse-can-ship-without-bidresponse-the-agent-has-nothing-to-shade-47k</link>
      <guid>https://dev.to/aleksuix/lifecycledspbidresponse-can-ship-without-bidresponse-the-agent-has-nothing-to-shade-47k</guid>
      <description>&lt;p&gt;A DSP-side ARTF host sets &lt;code&gt;lifecycle&lt;/code&gt; to &lt;code&gt;LIFECYCLE_DSP_BID_RESPONSE&lt;/code&gt;, keeps the publisher &lt;code&gt;bid_request&lt;/code&gt; on the envelope, and omits &lt;code&gt;bid_response&lt;/code&gt;. The gRPC call completes. The agent returns &lt;code&gt;BID_SHADE&lt;/code&gt; on &lt;code&gt;/seatbid/dsp-1/bid/bid-abc&lt;/code&gt;. The orchestrator has no bid with that id in the payload it actually received.&lt;/p&gt;

&lt;p&gt;That is not a shading bug in the agent. It is an envelope that names a stage without attaching the document that stage requires.&lt;/p&gt;

&lt;p&gt;The IAB Tech Lab &lt;a href="https://iabtechlab.com/standards/artf/" rel="noopener noreferrer"&gt;Agentic Real Time Framework&lt;/a&gt; (ARTF) puts a containerized agent beside an SSP, exchange, or DSP. The host sends an &lt;code&gt;RTBRequest&lt;/code&gt; with a &lt;code&gt;lifecycle&lt;/code&gt; value, a &lt;code&gt;tmax&lt;/code&gt; budget, the OpenRTB object under mutation, and &lt;code&gt;applicable_intents&lt;/code&gt;: the vocabulary the orchestrator is willing to evaluate. The agent answers with &lt;code&gt;Mutation&lt;/code&gt; messages. Each mutation has an intent, an operation, a semantic path, and a typed payload.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BID_SHADE&lt;/code&gt; is a response-stage intent. It adjusts &lt;code&gt;bid.price&lt;/code&gt; on a specific seat and bid id. The path grammar is explicit: &lt;code&gt;/seatbid/{seat}/bid/{bid id}&lt;/code&gt;. There is no impression index to fall back on. If &lt;code&gt;bid_response&lt;/code&gt; is missing, every shading path is a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  lifecycle is a label on the call, not proof of contents
&lt;/h2&gt;

&lt;p&gt;ARTF defines lifecycle stages such as &lt;code&gt;LIFECYCLE_PUBLISHER_BID_REQUEST&lt;/code&gt; and &lt;code&gt;LIFECYCLE_DSP_BID_RESPONSE&lt;/code&gt;. The name tells the agent which intents are meaningful on this hop. It does not inject the matching OpenRTB member.&lt;/p&gt;

&lt;p&gt;A response-stage call still has to carry &lt;code&gt;bid_response&lt;/code&gt; when the agent is expected to read prices and ids from it. RTBlint reports the mismatch as &lt;a href="https://rtblint.org/docs/rule-reference/artf-lifecycle-payload_mismatch/" rel="noopener noreferrer"&gt;&lt;code&gt;artf.lifecycle.payload_mismatch&lt;/code&gt;&lt;/a&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;"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;"ep-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="mi"&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;"lifecycle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"LIFECYCLE_DSP_BID_RESPONSE"&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_request"&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;"a-1"&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;The envelope declares the DSP bid response stage and carries no &lt;code&gt;bid_response&lt;/code&gt; member. Intents that belong to that stage, &lt;code&gt;BID_SHADE&lt;/code&gt; above all, address a bid by seat and bid id. The agent is being asked to reason about an answer it was never shown.&lt;/p&gt;

&lt;p&gt;The symmetric failure at the publisher stage is sending &lt;code&gt;BID_SHADE&lt;/code&gt; when only &lt;code&gt;bid_request&lt;/code&gt; is present and no bid exists yet. That combination triggers &lt;a href="https://rtblint.org/docs/rule-reference/artf-mutation-intent_not_applicable/" rel="noopener noreferrer"&gt;&lt;code&gt;artf.mutation.intent_not_applicable&lt;/code&gt;&lt;/a&gt; together with a missing target payload. Shading before any bid is made is not a floor tweak; it is the wrong lifecycle for the intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  applicable_intents is the other half of the contract
&lt;/h2&gt;

&lt;p&gt;The orchestrator also publishes &lt;code&gt;applicable_intents&lt;/code&gt;: which mutation types it will even consider on this extension point.&lt;/p&gt;

&lt;p&gt;Without that list, the agent invents work the host may reject. RTBlint flags &lt;a href="https://rtblint.org/docs/rule-reference/artf-intents-missing/" rel="noopener noreferrer"&gt;&lt;code&gt;artf.intents.missing&lt;/code&gt;&lt;/a&gt; when the field is absent entirely.&lt;/p&gt;

&lt;p&gt;When the field is present but not a JSON array, protojson refuses it. A single intent written as a bare string looks natural in hand-authored JSON and fails on the wire:&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;"applicable_intents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ACTIVATE_DEALS"&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;Protobuf repeated fields serialize as arrays. The fix is &lt;code&gt;["ACTIVATE_DEALS"]&lt;/code&gt;, even for one intent. That encoding error is &lt;a href="https://rtblint.org/docs/rule-reference/artf-intents-not_array/" rel="noopener noreferrer"&gt;&lt;code&gt;artf.intents.not_array&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If the agent returns an intent outside the declared list, the mutation is dead on arrival: &lt;a href="https://rtblint.org/docs/rule-reference/artf-mutation-intent_not_applicable/" rel="noopener noreferrer"&gt;&lt;code&gt;artf.mutation.intent_not_applicable&lt;/code&gt;&lt;/a&gt;. That is expensive inside a 120 ms &lt;code&gt;tmax&lt;/code&gt; window.&lt;/p&gt;

&lt;h2&gt;
  
  
  When bid_response is present but the path still misses
&lt;/h2&gt;

&lt;p&gt;Hosts that do attach &lt;code&gt;bid_response&lt;/code&gt; can still lose shading if the path names a seat or bid id that is not in that document. &lt;code&gt;BID_SHADE&lt;/code&gt; does not address bids by array offset. A typo in the seat name is not recoverable by scanning nearby objects.&lt;/p&gt;

&lt;p&gt;RTBlint reports that as &lt;a href="https://rtblint.org/docs/rule-reference/artf-mutation-bid_unknown/" rel="noopener noreferrer"&gt;&lt;code&gt;artf.mutation.bid_unknown&lt;/code&gt;&lt;/a&gt;. The mutation looks structurally fine. The price never changes because the target bid was never there.&lt;/p&gt;

&lt;h2&gt;
  
  
  How you catch it before the auction forwards nonsense
&lt;/h2&gt;

&lt;p&gt;I maintain &lt;a href="https://rtblint.org" rel="noopener noreferrer"&gt;RTBlint&lt;/a&gt;, an open source OpenRTB and ARTF linter. Envelope checks run before mutation checks, because the lifecycle and intent list define what the response is allowed to propose.&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; artf-request rtb-request.json
&lt;span class="go"&gt;FAILED (OpenRTB 2.6-202606 ARTF request envelope): 1 error(s), 0 warning(s).
- [error] lifecycle: envelope declares LIFECYCLE_DSP_BID_RESPONSE
  but carries no bid_response. (artf.lifecycle.payload_mismatch)

&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;--type&lt;/span&gt; artf-response &lt;span class="nt"&gt;--request&lt;/span&gt; rtb-request.json rtb-response.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass two pairs each mutation against the request's &lt;code&gt;applicable_intents&lt;/code&gt; and against semantic paths in the payloads actually carried. Pass three applies accepted mutations and revalidates OpenRTB so you see field-level damage (wrong metric type, deal id the exchange does not list) rather than blaming the original emit.&lt;/p&gt;

&lt;p&gt;The same rules run in the &lt;a href="https://rtblint.org/demo/artf/" rel="noopener noreferrer"&gt;ARTF simulator&lt;/a&gt;, the &lt;a href="https://rtblint.org/tester/" rel="noopener noreferrer"&gt;browser tester&lt;/a&gt;, MCP tools, and gRPC on &lt;code&gt;openadtech.rtblint.v1&lt;/code&gt;. ARTF already mandates gRPC for the extension point; validating on that channel beats shelling out after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to read the rest of the surface
&lt;/h2&gt;

&lt;p&gt;The eight intents and what each one touches on OpenRTB are laid out in &lt;a href="https://rtblint.org/blog/artf-mutations-bid-request-validation/" rel="noopener noreferrer"&gt;ARTF mutations and bid request validation&lt;/a&gt;. That post is the map for checkpoint validation after every mutating stage, not only at the SSP edge.&lt;/p&gt;

&lt;p&gt;For field-by-field context on the envelope itself, &lt;a href="https://rtblint.org/guides/artf-explained/" rel="noopener noreferrer"&gt;ARTF explained&lt;/a&gt; walks the request and response members without assuming you have read the PDF first.&lt;/p&gt;

&lt;p&gt;RTBlint is independent of IAB Tech Lab. The rule ids above come from the public ARTF proto and the v1.0 document; they exist because lifecycle, intents, and paths are contracts you can violate while the RPC still returns OK.&lt;/p&gt;

</description>
      <category>grpc</category>
      <category>openrtb</category>
      <category>adtech</category>
      <category>api</category>
    </item>
    <item>
      <title>AAMP Can Finish a Video Buy With a Wrapper. It Cannot Prove the InLine Exists.</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Thu, 27 Aug 2026 23:20:11 +0000</pubDate>
      <link>https://dev.to/aleksuix/aamp-can-finish-a-video-buy-with-a-wrapper-it-cannot-prove-the-inline-exists-3b6c</link>
      <guid>https://dev.to/aleksuix/aamp-can-finish-a-video-buy-with-a-wrapper-it-cannot-prove-the-inline-exists-3b6c</guid>
      <description>&lt;p&gt;An AAMP buyer agent completes a programmatic video buy and returns a VAST tag URL. The task status is success. The deal is in the Deals Library. Nobody has followed the URL.&lt;/p&gt;

&lt;p&gt;Two weeks later fill is at forty percent and the report says "no ad response." The tag the agent negotiated is a &lt;code&gt;&amp;lt;Wrapper&amp;gt;&lt;/code&gt; pointing at another host. That second hop is another wrapper. The third hop times out on CTV. Google IMA stops at four hops. The slot goes empty. Nothing in the agent transcript says anything failed, because nothing in the agent layer required anyone to resolve the chain.&lt;/p&gt;

&lt;p&gt;IAB Tech Lab's Agentic Advertising Management Protocols (AAMP) is explicit about keeping VAST as the video envelope. OpenRTB stays the auction rail. AdCOM stays the object model. The agent negotiates above or beside those layers, and what ships to the player is still XML. That design is coherent. It also means the last mile is identical to the last mile you already had: a URL that may be a redirect graph, not a creative.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "delivery" means in an agentic buy
&lt;/h2&gt;

&lt;p&gt;AAMP 2.0 brought programmatic into scope with buyer and seller agent SDKs that can transact over MCP. AAMP 2.3 added operational gates: diligence checks, server-side trust verification on price-moving paths, deployment targets on Bedrock and Databricks. Those are real controls on money and authorization.&lt;/p&gt;

&lt;p&gt;None of them fetch VAST.&lt;/p&gt;

&lt;p&gt;Read the &lt;a href="https://rtblint.org/guides/iab-agentic-advertising-standards/" rel="noopener noreferrer"&gt;IAB agentic advertising standards guide&lt;/a&gt; next to the &lt;a href="https://rtblint.org/guides/agentic-buying-openrtb/" rel="noopener noreferrer"&gt;agentic buying and OpenRTB primer&lt;/a&gt; and the split is obvious. ARTF agents may rewrite bid requests mid-auction. AdCP-style negotiation may produce a deal ID that shows up in &lt;code&gt;imp.pmp.deals&lt;/code&gt;. Either way, when the format is video, execution still collapses to a tag endpoint.&lt;/p&gt;

&lt;p&gt;That endpoint can legally be hop one of a wrapper chain. VAST 2.0 through 4.x all allow a &lt;code&gt;&amp;lt;Wrapper&amp;gt;&lt;/code&gt; with a &lt;code&gt;&amp;lt;VASTAdTagURI&amp;gt;&lt;/code&gt; instead of an &lt;code&gt;&amp;lt;InLine&amp;gt;&lt;/code&gt;. SSPs, DSPs, and measurement vendors stack wrappers because each hop carries its own impression and error beacons. The pattern is normal. Treating the first URL as proof of delivery is not.&lt;/p&gt;

&lt;p&gt;A wrapper-only response validates against schema. It can return HTTP 200 with well-formed XML and still never reach media. The player needs an &lt;code&gt;&amp;lt;InLine&amp;gt;&lt;/code&gt; with a &lt;code&gt;&amp;lt;MediaFile&amp;gt;&lt;/code&gt; somewhere downstream, within a depth budget that varies by platform (four hops on IMA and most CTV stacks). An agent that stops at the negotiated URI has verified negotiation, not playback.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure modes wrappers hide
&lt;/h2&gt;

&lt;p&gt;These are the ones I see after an agent handoff, not the ones a JSON Schema catches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Depth exceeded.&lt;/strong&gt; Four wrappers before the inline is common in open market paths. A fifth hop fails silently on CTV. The outer wrapper often has no &lt;code&gt;&amp;lt;Error&amp;gt;&lt;/code&gt; URL, so reporting shows underdelivery with no VAST error code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Circular or dead URIs.&lt;/strong&gt; A misconfigured ad server points &lt;code&gt;&amp;lt;VASTAdTagURI&amp;gt;&lt;/code&gt; back to itself or to a 404 that returns HTML with a 200 status. The agent never requested the body. The &lt;a href="https://vastlint.org/guides/vast-wrapper-chains/" rel="noopener noreferrer"&gt;wrapper chains guide&lt;/a&gt; lists platform limits and the checklist; circular chains are almost always configuration, and they are invisible until something walks the graph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;followAdditionalWrappers="false"&lt;/code&gt; mid-chain.&lt;/strong&gt; A wrapper that sets this attribute tells the player to stop even if the next response is another wrapper. The chain aborts at that hop. Schema-valid, unplayable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong host on the inline.&lt;/strong&gt; The wrapper you tested is not the creative that plays. The InLine lives on a different CDN with a different duration and a different tracking set. That is a separate bug, but wrapper-first QA makes it worse because you never saw the terminal document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version drift across hops.&lt;/strong&gt; Hop one declares VAST 4.2. Hop three uses 3.0 elements. Players pick rules per document. An agent that recorded "4.2" in a deal artifact did not open either file.&lt;/p&gt;

&lt;p&gt;AdCP's creative protocol has the same blind spot at the asset layer: a manifest can declare a VAST URL and optional metadata without parsing the tag. &lt;a href="https://vastlint.org/blog/adcp-vast-asset-declared-vs-verified/" rel="noopener noreferrer"&gt;AdCP can declare a VAST tag; it cannot check one&lt;/a&gt;. AAMP and AdCP disagree about where agents sit, but they agree that VAST stays the envelope. Neither specification resolves wrappers for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to catch it before the flight
&lt;/h2&gt;

&lt;p&gt;Split the job the way players split it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step one: the live tag URL in the &lt;a href="https://vastlint.org/tester/" rel="noopener noreferrer"&gt;VAST tag tester&lt;/a&gt;.&lt;/strong&gt; Paste the URI the agent returned, not a saved XML snippet from an earlier debug session. The tester fetches what the endpoint serves now, previews the creative when it can, and surfaces tracking and click URLs on the response it actually got. If hop one is a wrapper, you see wrapper structure immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step two: the same URL in the &lt;a href="https://vastlint.org/inspect/" rel="noopener noreferrer"&gt;VAST inspector&lt;/a&gt;.&lt;/strong&gt; The inspector resolves hop by hop. It reports chain depth, where each &lt;code&gt;&amp;lt;VASTAdTagURI&amp;gt;&lt;/code&gt; points, and whether the chain terminates in an &lt;code&gt;&amp;lt;InLine&amp;gt;&lt;/code&gt;. That is the check the agent task skipped. If depth is four and your target platform is Roku or Fire TV, you already know you are at the limit before the first impression.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step three: validate the terminal XML.&lt;/strong&gt; Once the inspector shows the inline, run structural rules on that document: &lt;code&gt;Duration&lt;/code&gt; in &lt;code&gt;HH:MM:SS&lt;/code&gt;, required &lt;code&gt;Impression&lt;/code&gt;, HTTPS media files, declared version against elements used. CLI or MCP if the agent pipeline needs automation; the tester and inspector are the human and agent-facing entry points.&lt;/p&gt;

&lt;p&gt;Wire this into the agent loop the same way you would wire OpenRTB validation after an ARTF mutation. The &lt;a href="https://rtblint.org/blog/agentic-version-negotiation-openrtb-gap/" rel="noopener noreferrer"&gt;version negotiation gap on OpenRTB&lt;/a&gt; is a parallel problem: the handshake succeeds while the payload downstream is wrong for the receiver. Here the receiver is the player, and the payload is three redirects away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the check belongs
&lt;/h2&gt;

&lt;p&gt;If you host a buyer agent, treat "tag URI received" as an intermediate state, not &lt;code&gt;COMPLETE&lt;/code&gt;. Resolve the chain (or call a tool that does) before writing the deal to your system of record.&lt;/p&gt;

&lt;p&gt;If you sit on the sell side, reject or flag creatives whose entry URL does not terminate in an inline within your platform's depth budget. The cost of a retry in the agent protocol is lower than the cost of a week of silent no-fill.&lt;/p&gt;

&lt;p&gt;If you are comparing stacks, the &lt;a href="https://rtblint.org/blog/aamp-adcp-openrtb-2026/" rel="noopener noreferrer"&gt;AAMP, AdCP, and OpenRTB landscape write-up&lt;/a&gt; is useful context, but the invariant is stack-agnostic: agents changed who negotiates; VAST still defines what renders.&lt;/p&gt;

&lt;p&gt;AAMP's trust work on price-moving paths is worth having. It does not replace proving the creative exists. The tag URL is an address, not an ad. Follow it.&lt;/p&gt;

</description>
      <category>vast</category>
      <category>adtech</category>
      <category>advertising</category>
      <category>api</category>
    </item>
    <item>
      <title>One CAPI JSON cannot serve every vendor</title>
      <dc:creator>Aleksander Sekowski</dc:creator>
      <pubDate>Wed, 26 Aug 2026 04:56:26 +0000</pubDate>
      <link>https://dev.to/aleksuix/one-capi-json-cannot-serve-every-vendor-56dg</link>
      <guid>https://dev.to/aleksuix/one-capi-json-cannot-serve-every-vendor-56dg</guid>
      <description>&lt;p&gt;The first CAPI integration is usually Meta. The second is a copy with the host changed. That is how LinkedIn returns 422, TikTok stamps arrival time, and Pinterest drops the event for a missing &lt;code&gt;event_id&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clock is the tell
&lt;/h2&gt;

&lt;p&gt;Meta CAPI &lt;code&gt;event_time&lt;/code&gt; is Unix seconds, 10 digits. LinkedIn &lt;code&gt;conversionHappenedAt&lt;/code&gt; is milliseconds, 13 digits. A seconds value on LinkedIn reads as 1970 and falls outside the 90-day window. TikTok Events API &lt;code&gt;timestamp&lt;/code&gt; is ISO 8601. An epoch number there is treated as arrival time. Pinterest CAPI &lt;code&gt;event_time&lt;/code&gt; is seconds again.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Date.now()&lt;/code&gt; is right for LinkedIn and wrong for Meta. &lt;code&gt;Math.floor(Date.now()/1000)&lt;/code&gt; is right for Meta and wrong for LinkedIn. &lt;code&gt;toISOString()&lt;/code&gt; is right for TikTok and a type error on Meta.&lt;/p&gt;

&lt;h2&gt;
  
  
  Required fields do not translate
&lt;/h2&gt;

&lt;p&gt;LinkedIn requires &lt;code&gt;user.userIds&lt;/code&gt; even when you match on &lt;code&gt;userInfo&lt;/code&gt; or lead. The field is often an empty array. Omit it and the API returns 422. Meta does not have that field.&lt;/p&gt;

&lt;p&gt;Pinterest CAPI requires &lt;code&gt;event_id&lt;/code&gt;. Meta treats &lt;code&gt;event_id&lt;/code&gt; as how you dedup against the pixel; people ship Meta without it and still see purchases. The same omission on Pinterest is invalid.&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;"event_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Purchase"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1770000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user_data"&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;"em"&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;"64ec88ca..."&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;h2&gt;
  
  
  HTTP 200 is still not the contract
&lt;/h2&gt;

&lt;p&gt;Validate the outbound body, not the warehouse row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pixellint validate json @meta.json &lt;span class="nt"&gt;--rulepack&lt;/span&gt; vendor/meta-conversions-api
pixellint validate json @li.json &lt;span class="nt"&gt;--rulepack&lt;/span&gt; vendor/linkedin-conversions-api
pixellint validate json @tt.json &lt;span class="nt"&gt;--rulepack&lt;/span&gt; vendor/tiktok-events-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A shared CAPI JSON is a bug, not a shortcut. Pixellint is not affiliated with Meta, LinkedIn, TikTok, or Pinterest. &lt;a href="https://pixellint.org/" rel="noopener noreferrer"&gt;Playground&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Original: &lt;a href="https://pixellint.org/docs/one-capi-json-cannot-serve-every-vendor/" rel="noopener noreferrer"&gt;One CAPI JSON cannot serve every vendor&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>api</category>
      <category>advertising</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
