A FAST channel can show healthy VAST fill in the ad server while the living-room player never requests a mid-roll tag. Trafficking exported a VMAP playlist from Google Ad Manager or a broadcaster CMS. QA opened the XML in a browser, saw AdBreak nodes with timeOffset values, and signed off. At the ten-minute mark the player hit the break window, found an AdSource with an empty AdTagURI, and skipped the commercial pod without firing breakStart or breakEnd.
The production bug is rarely a player that ignores VMAP on principle. It is a playlist that parses as XML but cannot bind a fetchable ad tag to a schedulable offset. Empty CDATA inside AdTagURI, a timeOffset written as minutes-only instead of hh:mm:ss.mmm, or a breakType the player does not implement for that surface all produce the same symptom: the content timeline reaches the break, nothing monetizes, and upstream logs still look like a valid schedule file shipped.
IAB Tech Lab published VMAP so content owners can describe break structure separately from the VAST that fills each break. This piece is the structural check on that playlist: required AdBreak attributes, AdSource content rules, and the failure modes that survive schema tools because the document is well-formed even when the player cannot act on it.

Why a valid root element is not a break schedule
Buyers reconcile delivery on VAST impressions and quartiles. Publishers reconcile pod revenue on breakStart and the VAST chain each break returns. Neither dashboard proves the VMAP handed the player a non-empty AdTagURI at the offset the content clock actually uses.
That split is expensive on CTV because the schedule file and the ad response are different HTTP transactions. The player reads VMAP once (or per period in live workflows), waits until timeOffset matches content progress, then GETs the AdTagURI. Validating only the first VAST tag in a spreadsheet misses the hop that decides whether a break ever requests ads.
SSAI paths add a third reader. The stitcher may consume VMAP to plan splice points while the client player consumes a manifest that already inlined segments. A VMAP that validates in isolation but uses percentage offsets against the wrong duration baseline schedules breaks the stitcher never sees.
Genre splits make the mistake repeatable. A VOD title with a fixed duration tolerates timestamp mid-rolls. A FAST loop with sliding windows needs start, end, or ordinal #m offsets. Copying a broadcast template without re-validating timeOffset format against the player SDK docs is how green XML still yields silent breaks.
What timeOffset actually controls
Each AdBreak requires timeOffset. VMAP accepts start, end, a clock value hh:mm:ss.mmm, a percentage such as 25%, or an ordinal position #2 meaning the second ad opportunity in the stream. The attribute tells the player when to open the break, not which creative plays.
Players map timeOffset against their content clock. A value of 15:00 is not a valid clock form unless your toolchain treats it as fifteen minutes with implicit seconds. vastlint reports VMAP-1.0-adbreak-timeoffset-format when the string does not match the spec grammar, because the mid-roll will not schedule where trafficking thinks it will.
repeatAfter adds recurring breaks for long-form and live-style loops. A repeatAfter that conflicts with breakId or duplicate offsets can warn as VMAP-1.0-repeatafter-conflict even when each AdBreak alone looks plausible. Validation catches static contradictions before the stream airs.
breakType is required and names linear, nonlinear, or display acceptance for the break. Comma-separated values appear in the wild. A linear breakType on a player configured for overlay-only breaks fails later in the SDK, but timeOffset and AdSource still need to be valid or the player never reaches the SDK branch.
AdSource, AdTagURI, and the fetch the player performs
AdSource must contain exactly one of AdTagURI, VASTAdData, or CustomAdData. AdTagURI is the common production path: a URL that returns VAST when the break triggers. templateType declares vast4, vast3, vast2, vast1, or proprietary so the player knows what parser to run after the GET.
allowMultipleAds and followRedirects are boolean attributes on AdSource. followRedirects false on a tag that only returns Wrapper chains stops monetization at hop one even when timeOffset is perfect. allowMultipleAds true on a break that trafficking sized for a single thirty-second spot affects pod sequencing inside the VAST response, not the VMAP schedule itself.
VASTAdData embeds a full VAST document inline. vastlint runs the VAST rule chain on embedded creatives and reports paths under AdBreak AdSource VASTAdData. A broken inline VAST makes the break look populated in XML while the player fails as soon as it parses the inner tag.
Empty AdTagURI is an error class of its own: VMAP-1.0-adtaguri-empty fires when CDATA is blank or whitespace. The break exists on the timeline with nothing to fetch. Seller-side fill reports stay flat while the content owner insists the schedule file was delivered.
VMAP checks before you traffic a CTV schedule
- Root vmap:VMAP declares version 1.0 and the videosuite namespace URI expected by IAB VMAP 1.0.
- Every AdBreak includes timeOffset and breakType; timeOffset matches start, end, hh:mm:ss.mmm, n%, or #m grammar.
- Each AdSource has non-empty AdTagURI in CDATA, or valid VASTAdData, or CustomAdData with CDATA where required.
- AdTagURI macros use CDATA so expanded query strings with ampersands stay well-formed XML.
- templateType on AdTagURI matches the VAST version your ad server actually returns for that placement.
- Embedded VAST under VASTAdData passes VAST validation on the same version declared inside the inner root.
- Break-level TrackingEvents URLs are HTTPS where the player environment requires TLS.
<?xml version="1.0" encoding="UTF-8"?><vmap:VMAP xmlns:vmap="http://www.iab.net/videosuite/vmap" version="1.0"> <vmap:AdBreak timeOffset="00:10:00.000" breakType="linear" breakId="midroll-1"> <vmap:AdSource id="mid-1" allowMultipleAds="true" followRedirects="true"> <vmap:AdTagURI templateType="vast4"> <![CDATA[https://ads.example.com/vast?pos=midroll&cb=[CACHEBUSTING]]]> </vmap:AdTagURI> </vmap:AdSource> <vmap:TrackingEvents> <vmap:Tracking event="breakStart"><![CDATA[https://t.example.com/break?id=midroll-1&e=start]]></vmap:Tracking> </vmap:TrackingEvents> </vmap:AdBreak></vmap:VMAP>Get VAST spec updates, platform guides, and release notes in your inbox.
What the player does with that document
The SDK loads the VMAP, registers the mid-roll at ten minutes of content time, and waits. When the content clock crosses 00:10:00.000 on a linear breakType, it fires breakStart if present, then GETs the AdTagURI with macro expansion applied.
followRedirects true allows Wrapper chains on the VAST response. allowMultipleAds true permits podded VAST inside the break. The VMAP job ends at a successful VAST handoff; quartiles and impressions remain VAST responsibilities.
If templateType says vast4 but the ad server returns a 3.0 root, parser mismatch surfaces as a VAST failure, not a VMAP failure. Keeping templateType honest is part of schedule QA because it sets player expectations before the fetch.
<?xml version="1.0" encoding="UTF-8"?><vmap:VMAP xmlns:vmap="http://www.iab.net/videosuite/vmap" version="1.0"> <vmap:AdBreak timeOffset="10:00" breakType="linear" breakId="midroll-bad"> <vmap:AdSource id="mid-empty" allowMultipleAds="false" followRedirects="true"> <vmap:AdTagURI templateType="vast4"> <![CDATA[]]> </vmap:AdTagURI> </vmap:AdSource> </vmap:AdBreak></vmap:VMAP>Why that shape still passes some QA
Generic XML validators see a well-formed VMAP with an AdBreak and AdSource tree. timeOffset 10:00 fails VMAP-1.0-adbreak-timeoffset-format because it is not hh:mm:ss.mmm, start, end, a percentage, or #m. Trafficking may have meant ten minutes but wrote a shorthand the spec does not accept.
Empty AdTagURI CDATA triggers VMAP-1.0-adtaguri-empty. The player reaches the break, has no URL to call, and skips monetization. breakStart may never fire because some SDKs only ping tracking after a successful ad source resolution.
Fix order: rewrite timeOffset to 00:10:00.000 or 10% if percentage was intended, paste a live ad tag URL inside CDATA, re-run validation on the exported file from the ad server rather than a hand-edited sample, and test one break on the target player OS before full rotation.
VMAP versus live cue workflows
Static VMAP assumes known break positions before playback. Live sports and event streams often generate VMAP per cue with timeOffset set to the current transport timestamp. The same rules apply: empty AdTagURI and bad timeOffset formats break monetization seconds before air.
Do not confuse this check with the live-event integration story. Per-cue generation is an ops pattern; this how-to is the envelope on each generated file. Validate the file the player or stitcher actually receives after cue substitution, not the VOD template in version control.
When SSAI consumes VMAP, confirm the stitched output still contains AdTagURI after any server-side rewrite. Some pipelines strip tracking or swap AdSource ids while leaving timeOffset intact, which looks like a fill problem in logs when the root cause is an empty URI on the stitched copy.
Validate VMAP before the break reaches the player
Paste a VMAP playlist or fetch the schedule URL. vastlint detects document type, checks timeOffset, AdTagURI, namespace, and embedded VAST on the same rule surface as VAST tags.
Open the validatorPrimary references
Video Multiple Ad Playlist specification and resources.
AdBreak, timeOffset, AdSource, and VMAP versus VAST on vastlint.org.
Empty AdTagURI prevents the player from fetching VAST for the break.
Invalid timeOffset strings leave breaks unscheduled.