Build a minimal OpenRTB bidder in Rust

This is the OpenRTB 2.6 bidder from the Node.js tutorial, rebuilt in Rust with axum 0.8, tokio, serde_json, and the rtblint-core crate. It validates each request, holds to a deadline derived from tmax, bids on the first banner or video impression it can fill, returns an empty 204 when it passes, and logs win notices. A unit test checks its bids against the request with the same validator. Every output on this page came from building and running it.

What you will build

A single binary with two routes: POST /bid for bid requests and /win for win notices. It returns the same answers as the Node.js bidder: 200 with a BidResponse for a bid, an empty 204 for a pass, 200 with BidResponse.nbr 2 for a request that fails validation, and an empty 400 for a body that is not JSON. That tutorial explains the protocol choices in more depth. This one focuses on how they look in Rust.

Download the files:

The build was tested with Rust 1.97.1. With the dependency versions current in November 2026 the minimum is Rust 1.89, set by the uuid crate. The code itself needs 1.80 for std::sync::LazyLock.

[package]
name = "openrtb-bidder-rust"
version = "0.1.0"
edition = "2021"
publish = false
description = "Minimal OpenRTB 2.6 bidder from the rtblint.org tutorial"

[dependencies]
axum = "0.8"
rtblint-core = "0.14.7"
serde_json = "1"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
uuid = { version = "1", features = ["v4"] }

Cargo resolved these to axum 0.8.9, tokio 1.54.1, serde_json 1.0.154, uuid 1.29.1, and rtblint-core 0.14.7. The crate is the same Rust validator that the npm package compiles to WebAssembly, so both bidders apply the same rules.

Configuration

//! Minimal OpenRTB 2.6 bidder: axum, tokio, serde_json, and rtblint-core.
//! Run with `cargo run --release`. Bid requests go to POST /bid.
use std::{collections::HashMap, env, sync::LazyLock};

use axum::{
    body::{to_bytes, Body},
    extract::Query,
    http::{Request, StatusCode},
    response::{IntoResponse, Json, Response},
    routing::{get, post},
    Router,
};
use rtblint_core::{validate_bid_request_for_version, OpenRtbVersion, Severity};
use serde_json::{json, Value};
use tokio::time::{timeout_at, Duration, Instant};

const SPEC: OpenRtbVersion = OpenRtbVersion::V2_6_202606; // pin the snapshot you validate against
const MAX_BODY_BYTES: usize = 256 * 1024;
const DEFAULT_TMAX_MS: u64 = 100; // used when a request carries no tmax
const NETWORK_RESERVE_MS: u64 = 40; // tmax also covers the trip back to the exchange
const BASE_CPM: f64 = 1.5; // USD
const MAX_CPM: f64 = 20.0;
const ADOMAIN: [&str; 1] = ["shop.example.com"];

SPEC is an OpenRtbVersion variant rather than a string, so a typo in the snapshot name fails at compile time. Pinning it keeps a crate upgrade from changing which rules apply without anyone noticing. NETWORK_RESERVE_MS is the slice of BidRequest.tmax left for the network, covered below with the deadline.

Creatives and settings

struct Creative {
    crid: &'static str,
    mtype: u8,
    w: u64,
    h: u64,
    dur: u64,
    adm: &'static str,
}

const BANNER: Creative = Creative {
    crid: "cr-banner-300x250",
    mtype: 1,
    w: 300,
    h: 250,
    dur: 0,
    adm: r#"<a href="https://shop.example.com/?src=rtb" target="_blank"><img src="https://cdn.example.com/cr-banner-300x250.png" width="300" height="250" alt="Example Shop"></a>"#,
};
const VIDEO: Creative = Creative {
    crid: "cr-video-15s",
    mtype: 2,
    w: 0,
    h: 0,
    dur: 15,
    adm: r#"<VAST version="4.2"><Ad id="cr-video-15s"><InLine><AdSystem version="1.0">example-bidder</AdSystem><AdTitle>Example Shop 15s</AdTitle><AdServingId>example-bidder-cr-video-15s</AdServingId><Impression><![CDATA[https://bidder.example.com/imp]]></Impression><Creatives><Creative id="cr-video-15s"><UniversalAdId idRegistry="unknown">unknown</UniversalAdId><Linear><Duration>00:00:15</Duration><MediaFiles><MediaFile delivery="progressive" type="video/mp4" width="1280" height="720"><![CDATA[https://cdn.example.com/cr-video-15s.mp4]]></MediaFile></MediaFiles></Linear></Creative></Creatives></InLine></Ad></VAST>"#,
};

static PORT: LazyLock<u16> = LazyLock::new(|| {
    env::var("PORT")
        .ok()
        .and_then(|p| p.parse().ok())
        .unwrap_or(8080)
});
static PUBLIC_URL: LazyLock<String> = LazyLock::new(|| {
    env::var("PUBLIC_URL").unwrap_or_else(|_| format!("http://localhost:{}", *PORT))
});

A Creative holds what a bid needs: the creative id, the markup type for Bid.mtype, the size or duration, and the markup itself. Raw string literals keep the HTML and VAST readable without escaping quotes. The port and public URL come from the environment once, on first use. PUBLIC_URL matters because it is written into every Bid.nurl.

Replies and impression matching

fn reply(status: StatusCode, body: Option<Value>) -> Response {
    let header = [("x-openrtb-version", "2.6")];
    match body {
        Some(body) => (status, header, Json(body)).into_response(),
        None => (status, header).into_response(),
    }
}

fn no_bid() -> Response {
    reply(StatusCode::NO_CONTENT, None)
}

/// The first banner or video imp this bidder has a creative for.
fn pick_imp(request: &Value) -> Option<(&Value, &'static Creative)> {
    for imp in request["imp"].as_array()? {
        if imp["bidfloorcur"].as_str().unwrap_or("USD") != "USD" {
            continue; // no FX table here
        }
        if let Some(b) = imp.get("banner") {
            let formats = b["format"].as_array().into_iter().flatten();
            if formats
                .chain([b])
                .any(|s| s["w"] == BANNER.w && s["h"] == BANNER.h)
            {
                return Some((imp, &BANNER));
            }
        }
        if let Some(v) = imp.get("video") {
            let mp4 = v["mimes"]
                .as_array()
                .is_some_and(|m| m.iter().any(|t| t == "video/mp4"));
            let min = v["minduration"].as_u64().unwrap_or(0);
            let max = v["maxduration"].as_u64().unwrap_or(u64::MAX);
            if mp4 && min <= VIDEO.dur && max >= VIDEO.dur {
                return Some((imp, &VIDEO));
            }
        }
    }
    None
}

reply adds x-openrtb-version: 2.6 to every answer, the response header the spec recommends for bidders. Json sets the content type.

pick_imp leans on how serde_json::Value indexing works: a missing key yields Null instead of panicking, and Value compares directly with integers and strings. That keeps the match rules close to the Node version. A banner fits when any entry of imp.banner.format, or the banner's own w and h, is 300x250. A video fits when imp.video.mimes includes video/mp4 and 15 seconds lies between imp.video.minduration and imp.video.maxduration. Impressions with a imp.bidfloorcur other than USD are skipped.

Price and response

/// Replace with your model, budget, and pacing calls. Prices are USD CPM.
async fn price_for(imp: &Value) -> Option<f64> {
    let floor = imp["bidfloor"].as_f64().unwrap_or(0.0);
    let price = BASE_CPM.max(((floor + 0.01) * 100.0).round() / 100.0);
    (price <= MAX_CPM).then_some(price)
}

fn bid_response(request: &Value, imp: &Value, creative: &Creative, price: f64) -> Value {
    let mut bid = json!({
        "id": uuid::Uuid::new_v4().to_string(),
        "impid": imp["id"],
        "price": price,
        "nurl": format!(
            "{}/win?auction=${{AUCTION_ID}}&imp=${{AUCTION_IMP_ID}}&price=${{AUCTION_PRICE}}",
            *PUBLIC_URL
        ),
        "adm": creative.adm,
        "adomain": ADOMAIN,
        "crid": creative.crid,
        "mtype": creative.mtype,
    });
    if creative.mtype == 1 {
        bid["w"] = json!(creative.w);
        bid["h"] = json!(creative.h);
    } else {
        bid["dur"] = json!(creative.dur);
    }
    json!({ "id": request["id"], "cur": "USD", "seatbid": [{ "bid": [bid] }] })
}

price_for is async because that is where real bidders wait on a model, a budget service, or a pacing store. Here it bids 1.50 USD CPM, or one cent above imp.bidfloor when the floor is higher, and declines above 20.00.

bid_response echoes the request id in BidResponse.id, gives the bid a UUID, points Bid.impid at the chosen impression, and fills Bid.adm, Bid.adomain, Bid.crid, and Bid.mtype. Banner bids add w and h; video bids add Bid.dur.

The macros in nurl must reach the exchange as literal text. In format!, braces are placeholders, so ${{AUCTION_PRICE}} is how you write a literal ${AUCTION_PRICE}. A single pair of braces would be a compile error, which beats the silent mistake the same line invites in other languages.

The request handler

async fn handle_bid(http: Request<Body>) -> Response {
    let arrived = Instant::now();
    // Malformed, oversized, or non-UTF-8 bodies get 400 with no content.
    let Ok(bytes) = to_bytes(http.into_body(), MAX_BODY_BYTES).await else {
        return reply(StatusCode::BAD_REQUEST, None);
    };
    let Ok(body) = std::str::from_utf8(&bytes) else {
        return reply(StatusCode::BAD_REQUEST, None);
    };
    let Ok(request) = serde_json::from_str::<Value>(body) else {
        return reply(StatusCode::BAD_REQUEST, None);
    };

    let report = validate_bid_request_for_version(SPEC, body);
    if !report.valid {
        let errors: Vec<_> = report
            .issues
            .iter()
            .filter(|i| i.severity == Severity::Error)
            .collect();
        println!(
            "{}",
            json!({ "event": "invalid_request", "id": request["id"], "errors": errors })
        );
        // nbr 2 is Invalid Request. Without a usable id there is nothing to echo.
        return match request["id"].as_str() {
            Some(id) => reply(StatusCode::OK, Some(json!({ "id": id, "nbr": 2 }))),
            None => reply(StatusCode::BAD_REQUEST, None),
        };
    }

    let tmax = request["tmax"].as_u64().unwrap_or(DEFAULT_TMAX_MS);
    let Some(budget) = tmax.checked_sub(NETWORK_RESERVE_MS).filter(|ms| *ms > 0) else {
        return no_bid();
    };
    let deadline = arrived + Duration::from_millis(budget);
    if let Some(cur) = request["cur"].as_array() {
        if !cur.iter().any(|c| c == "USD") {
            return no_bid();
        }
    }
    let Some((imp, creative)) = pick_imp(&request) else {
        return no_bid();
    };
    let badv = request["badv"].as_array().into_iter().flatten();
    if badv.filter_map(Value::as_str).any(|d| ADOMAIN.contains(&d)) {
        return no_bid();
    }

    match timeout_at(deadline, price_for(imp)).await {
        Ok(Some(price)) if Instant::now() <= deadline => reply(
            StatusCode::OK,
            Some(bid_response(&request, imp, creative, price)),
        ),
        _ => no_bid(), // no price, or the budget ran out
    }
}

The handler takes the raw Request and reads the body with to_bytes, capped at 256 KB. That ordering is deliberate. With a Bytes extractor, axum would read the whole body before the handler starts, and the clock would start late. Here, arrived is recorded first. A body that is too large, not UTF-8, or not JSON gets an empty 400.

validate_bid_request_for_version takes the pinned version and the raw text and returns a ValidationResult with valid and a list of Issue values. Each issue carries a rule id, a Severity, a message, and a JSON path. Issue implements Serialize, so the errors go straight into the log line. A request with errors gets {"id": ..., "nbr": 2}, the Invalid Request reason, or a 400 when it has no string id to echo.

The deadline uses checked_sub: when tmax is not larger than the network reserve, there is no budget and the bidder passes immediately. Otherwise timeout_at runs price_for against the deadline. Because tokio polls the inner future before checking the timer, a future that is ready at once could still slip through after the deadline, so the match arm checks the clock again. After that come the business rules: currencies in BidRequest.cur, the impression match, and blocked domains in BidRequest.badv.

Win notices and the router

/// The exchange calls nurl after substituting the macros with real values.
async fn handle_win(Query(q): Query<HashMap<String, String>>) -> Response {
    let price = q.get("price").and_then(|p| p.parse::<f64>().ok());
    let event = if price.is_some() {
        "win"
    } else {
        "win_without_price"
    };
    let line =
        json!({ "event": event, "auction": q.get("auction"), "imp": q.get("imp"), "price": price });
    println!("{line}");
    reply(StatusCode::NO_CONTENT, None) // a win notice that returns no markup answers 204
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/bid", post(handle_bid))
        .route("/win", get(handle_win).post(handle_win));
    let listener = tokio::net::TcpListener::bind(("0.0.0.0", *PORT))
        .await
        .expect("bind port");
    println!("bidder listening on {}", *PUBLIC_URL);
    axum::serve(listener, app).await.expect("server error");
}

The exchange replaces the macros in nurl before calling it, by GET or POST, so both methods route to handle_win. A price that does not parse means the macro was not substituted; the line is logged as win_without_price rather than as a zero-price win. The answer is 204, since the notice returns no markup.

The unit test

#[cfg(test)]
mod tests {
    use super::*;

    const REQUEST: &str = r#"{
        "id": "req-1", "tmax": 120, "cur": ["USD"],
        "imp": [{ "id": "1", "bidfloor": 0.85, "banner": { "format": [{ "w": 300, "h": 250 }] } }],
        "site": { "domain": "news.example.com", "page": "https://news.example.com/a" }
    }"#;

    #[tokio::test]
    async fn bid_passes_validation_against_its_request() {
        let request: Value = serde_json::from_str(REQUEST).unwrap();
        let (imp, creative) = pick_imp(&request).expect("an imp to bid on");
        let price = price_for(imp).await.expect("a price");
        let response = bid_response(&request, imp, creative, price).to_string();
        let report = rtblint_core::validate_bid_response_against_request(SPEC, REQUEST, &response);
        assert!(report.issues.is_empty(), "{:#?}", report.issues);
    }
}

The test drives the same functions the handler uses, then calls validate_bid_response_against_request. That checks the bid on its own and against the request: the impression id exists, mtype matches a format the impression offers, the response id echoes the request id, and the currency is allowed. It asserts on an empty issue list, so warnings fail it too.

$ cargo test --release
# cargo's compile output omitted
running 1 test
test tests::bid_passes_validation_against_its_request ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

To confirm the test bites, change the response id to a constant and run it again. It fails with the rule that caught it:

// in bid_response: "id": request["id"] changed to "id": "resp-1"
$ cargo test --release
running 1 test
test tests::bid_passes_validation_against_its_request ... FAILED

failures:

---- tests::bid_passes_validation_against_its_request stdout ----

thread 'tests::bid_passes_validation_against_its_request' (38873667) panicked at src/main.rs:233:9:
[
    Issue {
        id: "openrtb.response.request_id_mismatch",
        severity: Error,
        message: "Response id \"resp-1\" does not echo the bid request id \"req-1\"; BidResponse.id must be the id of the request it answers.",
        path: Some(
            "id",
        ),
        section: Some(
            "4.2.1",
        ),
    },
]

Run it

$ cargo run --release
# cargo's compile output omitted
bidder listening on http://localhost:8080

Post the sample request from a second terminal:

$ curl -s -X POST http://localhost:8080/bid \
    -H 'content-type: application/json' \
    --data @sample-request.json -o response.json -w '%{http_code}\n'
200
$ jq . response.json
{
  "cur": "USD",
  "id": "8f2b6c1e-4d7a-4a51-9b3e-2c0d5f7a9e10",
  "seatbid": [
    {
      "bid": [
        {
          "adm": "<a href=\"https://shop.example.com/?src=rtb\" target=\"_blank\"><img src=\"https://cdn.example.com/cr-banner-300x250.png\" width=\"300\" height=\"250\" alt=\"Example Shop\"></a>",
          "adomain": [
            "shop.example.com"
          ],
          "crid": "cr-banner-300x250",
          "h": 250,
          "id": "3a226300-76b2-4702-81ba-f66b4f28f948",
          "impid": "1",
          "mtype": 1,
          "nurl": "http://localhost:8080/win?auction=${AUCTION_ID}&imp=${AUCTION_IMP_ID}&price=${AUCTION_PRICE}",
          "price": 1.5,
          "w": 300
        }
      ]
    }
  ]
}

The keys come out in alphabetical order because serde_json stores objects in a sorted map unless you enable its preserve_order feature. JSON object order carries no meaning, so exchanges do not care. Check the pair with the RTBlint CLI, which runs the same crate:

$ rtblint validate --type response --request sample-request.json response.json
OK (OpenRTB 2.6-202606 bid response): no issues found.
$ echo $?
0

Check the other paths

# Not JSON: 400, no body
$ curl -s -X POST http://localhost:8080/bid --data '{"id": "abc", "imp": [' -w '%{http_code}\n'
400

# Floor above the 20.00 cap: 204, no body
$ jq '.imp[0].bidfloor = 25' sample-request.json \
    | curl -s -X POST http://localhost:8080/bid --data @- -w '%{http_code}\n'
204

# Floor 2.40: the bid moves one cent above it
$ jq '.imp[0].bidfloor = 2.4' sample-request.json \
    | curl -s -X POST http://localhost:8080/bid --data @- | jq '.seatbid[0].bid[0].price'
2.41

# No imp array: the request fails validation, so the answer is nbr 2
$ jq 'del(.imp)' sample-request.json \
    | curl -s -X POST http://localhost:8080/bid --data @- -w '\n%{http_code}\n'
{"id":"8f2b6c1e-4d7a-4a51-9b3e-2c0d5f7a9e10","nbr":2}
200

# tmax 30 is less than the 40 ms network reserve: 204 at once
$ jq '.tmax = 30' sample-request.json \
    | curl -s -X POST http://localhost:8080/bid --data @- -w '%{http_code}\n'
204

The deadline path was also checked with a build whose price_for sleeps for 100 ms. With tmax 120 the budget is 80 ms, and the bidder answered 204 about 82 ms after the request was sent. With tmax 300 the same build bid normally.

Send a win notice

$ jq -r '.seatbid[0].bid[0].nurl' response.json \
    | sed -e 's/${AUCTION_ID}/8f2b6c1e-4d7a-4a51-9b3e-2c0d5f7a9e10/' \
          -e 's/${AUCTION_IMP_ID}/1/' \
          -e 's/${AUCTION_PRICE}/1.23/' \
    | xargs curl -s -o /dev/null -w '%{http_code}\n'
204

The server prints one JSON line per event:

bidder listening on http://localhost:8080
{"errors":[{"id":"openrtb.field.required","message":"imp is required on OpenRTB 2.6-202606 BidRequest.","path":"imp","section":"3.2.1","severity":"error"}],"event":"invalid_request","id":"8f2b6c1e-4d7a-4a51-9b3e-2c0d5f7a9e10"}
{"auction":"8f2b6c1e-4d7a-4a51-9b3e-2c0d5f7a9e10","event":"win","imp":"1","price":1.23}

Same fixtures, same results

The fixture suite from Test an OpenRTB bidder with fixtures in CI was written against the Node.js bidder. Run unchanged against the Rust binary, it passes:

$ bash scripts/test-bidder.sh fixtures
ok    bid/banner-300x250.json
ok    bid/ctv-video-15s.json
ok    no-bid/blocked-adomain.json
ok    no-bid/floor-above-cap.json
ok    no-bid/tmax-too-short.json
ok    invalid/missing-imp.json
ok    malformed/truncated.json
all fixtures passed
$ echo $?
0

That is the practical meaning of same behavior: identical status codes, and bids that pass validation against their requests with no findings. The CI tutorial shows the workflow steps for building and starting this binary. Before real traffic, the list of gaps at the end of the Node.js tutorial applies here too: multiple impressions, deals, privacy signals, gzip, and loss notices.

Questions people ask

Why does the handler read the body itself instead of using axum's Bytes extractor?

Timing. An extractor reads the whole body before the handler runs, so a clock started inside the handler would miss the upload time. Taking the raw Request and calling to_bytes lets the bidder record its arrival time first, which keeps the tmax deadline honest.

Why use serde_json::Value instead of typed structs?

The bidder reads about a dozen fields and validates the raw text with rtblint-core before trusting any of them, so dynamic access stays short and never panics: indexing a missing key returns Null. Typed structs with serde derive work well too, once validation has passed. Unknown fields are ignored by default, so exchange extensions do not break deserialization.

Does the Rust bidder behave exactly like the Node.js one?

On the protocol, yes. Both return the same status codes for every bid request and win notice, and the same fixture suite passes against both. The differences sit outside the protocol: serde_json writes object keys in alphabetical order, and axum answers GET /bid with 405 where the Node version answers 404.