vastlint

Embedding vastlint in an SSP, DSP, or ad server

vastlint is built for in-process validation inside ad tech infrastructure. Embed vastlint-core directly in your bid handler, SSAI stitcher, or ad server to validate every VAST response before committing the impression — no subprocess, no network round-trip, no external dependency to manage.

A typical OpenRTB bid cycle has 100–300 ms to work with. vastlint validates a 17 KB production VAST tag in 363 µs on a single core, adding less than 2.1% of the bid budget on the heaviest 44 KB tags. An SSAI platform doing 1,000 stitches/sec spends more time on DNS than on VAST validation.

Architectural diagram

VAST ad flow: wrapper chain to viewer

SSP / AD SERVERvastlint returns ValidationResult → ad server code decidesSERVING-SIDE DECISIONVIEWER / DELIVERYVAST wrappervalidate per hopad server: servead server: serve✗ invalid↺ fallbackDemand PartnerVAST wrapper tagChain Resolverfollows 1–4 hopsvastlint-core363 µs / tagAd Clientweb / mobileCTV / OTTvia SSAI stitcherRejectedFallback VASTvastlint validates before serving — reject or swap in <2.1% of the bid budgetvastlint validates before any impression fires — reject or swap in <2.1% of bid budgetvalid tagrejected (error)fallback served

Why validate at bid time?

VAST errors are silent. A missing <Impression>, a malformed duration, a VPAID tag on a CTV device — none of these produce a player error you can act on. The impression is lost, the tracking fires (or doesn't), and a discrepancy surfaces in a report three days later. By the time you see it, the revenue is gone.

Validating inline, before the bid is committed, lets you:

  • Reject invalid tags and serve a fallback in the same auction window
  • Return structured rule IDs to the demand partner so they can fix the creative at the source
  • Track per-partner error rates and escalate when they spike
  • Filter by revenue-impact rules to avoid false positives on advisory-only issues

Rust — vastlint-core

vastlint-core is a zero-dependency Rust library. Three compile-time dependencies (quick-xml, url, phf), no runtime dependencies. Rules are compiled Rust functions — no regex engine, no schema interpreter, no allocator pressure beyond the parse.

# Cargo.toml
[dependencies]
vastlint-core = "0.4"

Basic validation:

use vastlint_core::validate;

let result = validate(vast_xml);

if !result.summary.is_valid() {
    for issue in &result.issues {
        // issue.id     — stable rule ID (e.g. "VAST-2.0-inline-impression")
        // issue.severity — "error", "warning", or "info"
        // issue.message — human-readable description
        // issue.path   — XPath location in the document
        eprintln!("[{}] {} — {}", issue.severity, issue.id, issue.path);
    }
}

With per-deployment rule overrides (e.g. relax a CTV-only rule for web inventory):

use std::collections::HashMap;
use vastlint_core::{validate_with_context, RuleLevel, ValidationContext};

let mut overrides = HashMap::new();
overrides.insert("VAST-4.1-mezzanine-recommended", RuleLevel::Off);
overrides.insert("VAST-2.0-mediafile-https", RuleLevel::Error); // promote to error

let ctx = ValidationContext {
    rule_overrides: Some(overrides),
    ..Default::default()
};

let result = validate_with_context(vast_xml, ctx);

Full API documentation is on docs.rs/vastlint-core.

Go — vastlint-go (no Rust toolchain required)

vastlint-go wraps the same Rust core via CGo. Prebuilt static libraries are bundled in the module — you do not need a Rust toolchain on your build or production machines. Supported platforms: Linux (amd64, arm64), macOS (amd64, arm64).

go get github.com/aleksUIX/vastlint-go

Basic validation:

import vastlint "github.com/aleksUIX/vastlint-go"

result, err := vastlint.Validate(xmlBytes)
if err != nil {
    log.Fatal(err)
}
if !result.Valid {
    for _, issue := range result.Issues {
        log.Printf("[%s] %s: %s", issue.Severity, issue.ID, issue.Message)
    }
}

With wrapper depth cap and per-deployment overrides:

result, err := vastlint.ValidateWithOptions(xmlBytes, vastlint.Options{
    MaxWrapperDepth: 5, // IAB-recommended maximum
    RuleOverrides: map[string]string{
        "VAST-4.1-mezzanine-recommended": "off",   // web inventory, no SSAI
        "VAST-2.0-mediafile-https":       "error",  // enforce HTTPS strictly
    },
})
if err != nil || !result.Valid {
    // Quarantine the tag. Return result.Issues to the demand partner.
}

Elixir / Erlang — vastlint-erlang

vastlint-erlang supports two integration modes. For production ad delivery, use the OTP port mode — vastlint-cli runs as a supervised OS process, so a crash or panic in the Rust layer is fully isolated and never affects the BEAM node. The NIF mode is available as an opt-in for non-critical paths.

OTP port mode — recommended for production ad delivery

Spawn vastlint daemon as an Erlang port under a NimblePool supervisor. The port communicates over stdin/stdout with newline-delimited JSON. A worker crash is isolated — the pool supervisor restarts it transparently. Add vastlint-cli to your deployment and nimble_pool to your mix.exs:

# mix.exs
defp deps do
  [{:nimble_pool, "~> 1.0"}]
end
defmodule MyApp.VastValidator do
  use NimblePool

  @cli_bin System.find_executable("vastlint") ||
             raise("vastlint binary not found on PATH")

  def start_link(opts \\ []) do
    NimblePool.start_link(worker: {__MODULE__, opts},
                          pool_size: System.schedulers_online(),
                          name: __MODULE__)
  end

  def validate(xml, timeout \\ 5_000) do
    NimblePool.checkout!(__MODULE__, :checkout, fn _from, port ->
      {call(port, xml, timeout), port}
    end, timeout)
  end

  @impl NimblePool
  def init_worker(_opts) do
    port = Port.open({:spawn_executable, @cli_bin},
                     [:binary, :use_stdio, {:packet, 4},
                      args: ["daemon"]])
    {:ok, port}
  end

  @impl NimblePool
  def handle_checkout(:checkout, _from, port, pool_state),
    do: {:ok, port, port, pool_state}

  @impl NimblePool
  def handle_checkin(port, _from, port, pool_state),
    do: {:ok, port, pool_state}

  @impl NimblePool
  def terminate_worker(_reason, port, _pool_state) do
    Port.close(port)
    :ok
  end

  defp call(port, xml, timeout) do
    # {:packet, 4} handles framing automatically — send raw XML binary.
    Port.command(port, xml)
    receive do
      {^port, {:data, json}} -> Jason.decode!(json, keys: :atoms)
    after
      timeout -> {:error, :timeout}
    end
  end
end
case MyApp.VastValidator.validate(xml_string) do
  %{valid: true}    -> :ok
  %{issues: issues} -> {:reject, issues}
  {:error, reason}  -> {:error, reason}
end

NIF mode — opt-in

The DirtyCpu NIF is available for non-critical paths where the ~10–50 µs port overhead matters. A NIF crash will take down the BEAM node — do not use this in any pipeline where node availability is required. Add the hex package to your mix.exs:

# mix.exs
defp deps do
  [
    {:vastlint, "~> 0.4", github: "aleksUIX/vastlint-erlang"}
  ]
end
case Vastlint.validate(xml_string) do
  {:ok, %{summary: %{errors: 0}}} ->
    :ok

  {:ok, %{issues: issues}} ->
    errors = Enum.filter(issues, &(&1.severity == "error"))
    {:reject, errors}

  {:error, reason} ->
    {:error, reason}
end

Production patterns

Filter by revenue impact before rejecting

Not every error warrants a hard rejection. Use the rules reference to identify which rule IDs map to impression-killing defects (missing <Impression>, bad <Duration> format, no <MediaFile>) versus advisory issues. Run two passes: hard-reject on errors, quarantine-for-review on warnings.

Return rule IDs to the demand partner

Every issue in the result includes a stable id field (e.g. VAST-2.0-inline-impression) and an XPath path pointing to the exact element. Return these in your bid response or partner notification — they are actionable without manual triage.

Wrapper depth cap

The IAB recommends a maximum wrapper chain depth of 5. Set MaxWrapperDepth: 5 (Go) or the equivalent context field in Rust to enforce this. The rule VAST-2.0-wrapper-depth fires when the depth is exceeded.

Self-hosting (air-gapped deployments)

All bindings run entirely in-process. There is no network code in vastlint-core — no callbacks, no telemetry, no license checks. For teams that also need the web validator or REST API on-premise, the Docker image is FROM scratch, under 5 MB, with a cold-start under 10 ms.

Performance reference

Benchmarked on Apple M4 (10-core), production-realistic VAST tags:

Tag sizeSingle-core throughputSingle-core latency10-core throughput
17 KB2,747 tags/sec363 µs15,760 tags/sec
44 KB475 tags/sec2,104 µs2,635 tags/sec

A typical OpenRTB bid cycle takes 100–300 ms; validation adds less than 2.1% of that budget even on the heaviest tags.

Enterprise support & integration consulting

Evaluating vastlint for production use in a DSP bid pipeline, SSAI platform, ad server, or CTV device? The author is available for integration consulting, SLA-backed enterprise support agreements, custom rule development, and on-site architecture review.

Typical engagements include:

  • Architecture review for in-process bid-time validation
  • Custom rule development for proprietary spec extensions or internal trafficking requirements
  • Per-partner error rate dashboards and alerting integration
  • Priority issue resolution and private disclosure channel
  • Air-gapped or AWS Marketplace deployment support

Email: aleks@vastlint.org

Or reach out through one of these channels:

Further reading