Build a minimal OpenRTB bidder in Node.js

A working OpenRTB 2.6 bidder fits in 146 lines of Node.js 22 with one dependency. This tutorial builds it piece by piece: read and validate the bid request, budget time against tmax, bid on the first banner or video impression it can fill, answer 204 when it passes, and log win notices. Every output on this page came from running the code.

What you will build

The bidder is a single file, server.mjs, on top of node:http. It has two endpoints: POST /bid takes bid requests and /win takes win notices. Request validation comes from rtblint-core 0.14.7, the npm build of the RTBlint validator. It runs as WebAssembly, so there is nothing native to compile.

Download the four files, or copy them from the sections below:

You need Node.js 22 or later, curl, and jq for the test commands.

{
  "name": "openrtb-bidder-node",
  "version": "1.0.0",
  "private": true,
  "description": "Minimal OpenRTB 2.6 bidder from the rtblint.org tutorial",
  "type": "module",
  "main": "server.mjs",
  "scripts": {
    "start": "node server.mjs"
  },
  "engines": {
    "node": ">=22"
  },
  "dependencies": {
    "rtblint-core": "0.14.7"
  }
}

How the bidder answers

OpenRTB rides on HTTP, and section 2.1 of the 2.6 spec sets the status codes. A call that returns content gets 200. A valid call that returns nothing gets 204. A malformed payload gets 400 with an empty body. The bidder maps its four outcomes onto those rules:

  • 200 with a BidResponse: a bid on one impression.
  • 204 with no body: no bid. Nothing matched, the floor was too high, the advertiser is blocked, the currency is not USD, or the time budget ran out.
  • 200 with only id and nbr: the request parsed but failed validation. BidResponse.nbr 2 means Invalid Request.
  • 400 with no body: the body was not JSON or was larger than 256 KB.

Configuration and creatives

// Minimal OpenRTB 2.6 bidder. Node 22, no framework, one dependency.
// Run: npm install && node server.mjs. Bid requests go to POST /bid.
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { validate } from "rtblint-core";

const PORT = Number(process.env.PORT ?? 8080);
const PUBLIC_URL = process.env.PUBLIC_URL ?? `http://localhost:${PORT}`;
const SPEC = "2.6-202606"; // pin the snapshot you validate against
const MAX_BODY_BYTES = 256 * 1024;
const DEFAULT_TMAX_MS = 100; // used when a request carries no tmax
const NETWORK_RESERVE_MS = 40; // tmax also covers the trip back to the exchange
const BASE_CPM = 1.5; // USD
const MAX_CPM = 20;
const ADOMAIN = ["shop.example.com"];

const BANNER = {
  crid: "cr-banner-300x250",
  w: 300,
  h: 250,
  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>',
};
const VIDEO = {
  crid: "cr-video-15s",
  dur: 15,
  adm: '<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>',
};

SPEC pins the OpenRTB snapshot the bidder validates against. Without a version argument, rtblint-core uses the newest snapshot it tracks, and that moves when you upgrade the package. A pinned value turns a spec upgrade into a one-line diff someone reviews.

The two creatives are constants: an HTML banner for 300x250 and a 15-second VAST 4.2 inline ad. A real bidder loads creatives from a store and matches them to campaigns. Keeping them fixed here leaves the protocol work in plain view. PUBLIC_URL is the address exchanges reach you on, because it ends up inside the win notice URL.

Answering and reading the body

function send(res, status, body) {
  res.setHeader("x-openrtb-version", "2.6");
  if (body === undefined) return res.writeHead(status).end();
  res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
}

async function readBody(req) {
  const chunks = [];
  let size = 0;
  for await (const chunk of req) {
    size += chunk.length;
    if (size > MAX_BODY_BYTES) throw new Error("request body too large");
    chunks.push(chunk);
  }
  return Buffer.concat(chunks).toString("utf8");
}

send adds x-openrtb-version: 2.6 to every answer. Section 2.5 of the spec asks exchanges to send that header with each request and recommends that bidders return it with the version they implement. readBody stops at 256 KB, so an oversized post fails fast instead of filling memory.

Choosing an impression

// The first banner or video imp this bidder has a creative for.
function pickImp(request) {
  for (const imp of request.imp) {
    if ((imp.bidfloorcur ?? "USD") !== "USD") continue; // no FX table here
    const b = imp.banner;
    const sizes = b ? [...(b.format ?? []), { w: b.w, h: b.h }] : [];
    if (sizes.some((s) => s.w === BANNER.w && s.h === BANNER.h)) {
      return { imp, creative: BANNER, mtype: 1 };
    }
    const v = imp.video;
    if (
      v?.mimes?.includes("video/mp4") &&
      (v.minduration ?? 0) <= VIDEO.dur &&
      (v.maxduration ?? Infinity) >= VIDEO.dur
    ) {
      return { imp, creative: VIDEO, mtype: 2 };
    }
  }
  return null;
}

A banner impression can offer several sizes. imp.banner.format lists them (it arrived in OpenRTB 2.4), while some integrations still put a single w and h on the banner itself, so the function checks both. A video impression fits when imp.video.mimes includes video/mp4 and 15 seconds falls between imp.video.minduration and imp.video.maxduration.

Impressions whose imp.bidfloorcur is not USD are skipped, because the bidder has no exchange rate table. When the field is absent, the spec default is USD.

Pricing inside the tmax budget

// Replace with your model, budget, and pacing calls. Prices are USD CPM.
async function priceFor(imp) {
  const floor = imp.bidfloor ?? 0;
  const price = Math.max(BASE_CPM, Number((floor + 0.01).toFixed(2)));
  return price <= MAX_CPM ? price : null;
}

// Resolves to null when the deadline passes first.
function withDeadline(promise, deadline) {
  const ms = deadline - performance.now();
  if (ms <= 0) return Promise.resolve(null);
  let timer;
  const expired = new Promise((resolve) => (timer = setTimeout(resolve, ms, null)));
  return Promise.race([promise, expired]).finally(() => clearTimeout(timer));
}

BidRequest.tmax is how many milliseconds the exchange will wait for bids, and that window includes the trip across the internet. A bidder that spends all of it computing still arrives late. So the handler sets its own deadline the moment a request arrives: arrival time, plus tmax, minus NETWORK_RESERVE_MS. When a request has no tmax, the bidder assumes 100 ms. Replace that with the value your exchange documents.

withDeadline races any async step against the deadline and returns null if time runs out first. priceFor is where model, budget, and pacing lookups belong, and those are the calls that blow a budget. In this version it bids 1.50 USD CPM, or one cent above imp.bidfloor when the floor is higher, and it passes when the price would exceed 20.00.

Building the BidResponse

function bidResponse(request, { imp, creative, mtype }, price) {
  const bid = {
    id: randomUUID(),
    impid: imp.id,
    price,
    nurl: `${PUBLIC_URL}/win?auction=` + "${AUCTION_ID}&imp=${AUCTION_IMP_ID}&price=${AUCTION_PRICE}",
    adm: creative.adm,
    adomain: ADOMAIN,
    crid: creative.crid,
    mtype,
    ...(mtype === 1 ? { w: creative.w, h: creative.h } : { dur: creative.dur }),
  };
  return { id: request.id, cur: "USD", seatbid: [{ bid: [bid] }] };
}

BidResponse.id echoes the request id so the exchange can match the answer to its auction. BidResponse.cur states the currency of every price in the response. Each bid carries its own Bid.id, an Bid.impid that names an impression from the request, and Bid.price as a CPM.

Bid.mtype, added in OpenRTB 2.6, says what kind of markup sits in Bid.adm: 1 for banner, 2 for video. Bid.adomain and Bid.crid let the exchange enforce advertiser blocks and track creative review. Banner bids carry w and h; video bids carry Bid.dur, the creative length in seconds.

Look closely at Bid.nurl. The macros ${AUCTION_ID}, ${AUCTION_IMP_ID}, and ${AUCTION_PRICE} have to reach the exchange as literal text, so they sit in a plain string. Inside a JavaScript template literal, ${AUCTION_PRICE} would be evaluated as an expression and the line would throw a ReferenceError. The AUCTION_PRICE macro reference lists what the exchange substitutes.

The response leaves out SeatBid.seat, Bid.burl, and Bid.lurl. Add a seat when the exchange assigns you one, and a billing URL when it bills on burl rather than on the win notice. The adm versus nurl guide covers how the notice URLs interact with markup delivery.

The request handler

async function handleBid(req, res, arrived) {
  let body, request;
  try {
    body = await readBody(req);
    request = JSON.parse(body);
  } catch {
    return send(res, 400); // malformed or oversized: 400, no content
  }

  const report = validate(body, SPEC);
  if (!report.valid) {
    const errors = report.issues.filter((i) => i.severity === "error");
    console.warn(JSON.stringify({ event: "invalid_request", id: request?.id, errors }));
    // nbr 2 is Invalid Request. Without a usable id there is nothing to echo.
    return typeof request?.id === "string" ? send(res, 200, { id: request.id, nbr: 2 }) : send(res, 400);
  }

  const deadline = arrived + (request.tmax ?? DEFAULT_TMAX_MS) - NETWORK_RESERVE_MS;
  if (request.cur && !request.cur.includes("USD")) return send(res, 204);
  const pick = pickImp(request);
  if (!pick) return send(res, 204);
  if (request.badv?.some((d) => ADOMAIN.includes(d))) return send(res, 204);

  const price = await withDeadline(priceFor(pick.imp), deadline);
  if (price === null || performance.now() > deadline) return send(res, 204);
  send(res, 200, bidResponse(request, pick, price));
}

The order of checks matters. Parsing comes first, and a body that is not JSON gets a 400. Validation comes second. A request that parses but breaks the spec gets { id, nbr: 2 } and the errors go to the log. Only a valid request reaches the business rules: the currency list in BidRequest.cur, the impression match, the blocked advertiser domains in BidRequest.badv, and the price.

Treating every validation error as fatal is strict. RTBlint reports a field the spec does not define, such as a vendor field placed at the top level instead of under ext, as openrtb.field.undefined with severity error. A production bidder usually rejects only the errors it cannot work around, like a missing imp or a wrong type, and logs the rest. Filter on the issue id to make that split.

Validation is cheap enough to run on every request. On an Apple Silicon Mac, 2,000 calls on the sample request averaged 0.024 ms. The first request after a cold start took about 9 ms to reach the pricing step while the WebAssembly module and the JIT warmed up, against about 0.3 ms afterward. Send a warm-up request before the bidder joins the pool.

Win notices and the server

// The exchange calls nurl after substituting the macros with real values.
function handleWin(url, res) {
  const q = url.searchParams;
  const price = parseFloat(q.get("price")); // NaN when the macro was not substituted
  const event = Number.isNaN(price) ? "win_without_price" : "win";
  console.log(JSON.stringify({ event, auction: q.get("auction"), imp: q.get("imp"), price }));
  send(res, 204); // a win notice that returns no markup answers 204
}

createServer(async (req, res) => {
  const arrived = performance.now();
  try {
    const url = new URL(req.url, PUBLIC_URL);
    if (url.pathname === "/win") return handleWin(url, res);
    if (url.pathname === "/bid" && req.method === "POST") return await handleBid(req, res, arrived);
    send(res, 404);
  } catch (err) {
    console.error(err);
    if (!res.headersSent) send(res, 500);
  }
}).listen(PORT, () => console.log(`bidder listening on ${PUBLIC_URL}`));

When you win, the exchange replaces the macros in nurl and calls it. The spec lets the exchange choose GET or POST for win notices, so the handler accepts any method on /win. If price does not parse as a number, the macro was not substituted. The handler logs that as win_without_price rather than booking a zero. It answers 204, the status for a win notice that returns no markup.

The try block around the routes turns an unexpected exception into a 500 for that request instead of a crashed process.

Run it

$ npm install
added 1 package in 315ms
$ node server.mjs
bidder listening on http://localhost:8080

In a second terminal, post the sample request and save the answer:

$ 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
{
  "id": "8f2b6c1e-4d7a-4a51-9b3e-2c0d5f7a9e10",
  "cur": "USD",
  "seatbid": [
    {
      "bid": [
        {
          "id": "3b42b7ef-a8be-4b74-a8e5-a4f81da4ac7a",
          "impid": "1",
          "price": 1.5,
          "nurl": "http://localhost:8080/win?auction=${AUCTION_ID}&imp=${AUCTION_IMP_ID}&price=${AUCTION_PRICE}",
          "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",
          "mtype": 1,
          "w": 300,
          "h": 250
        }
      ]
    }
  ]
}

The price is 1.50 because the sample floor is 0.85. The bid targets imp 1, carries mtype 1 for the banner, and the nurl still holds its macros, exactly as the exchange should receive them.

Validate the response against the request

A response can be valid on its own and still wrong for the request it answers: an impid that does not exist, a video mtype on a banner impression, a currency the request did not allow. validateResponseAgainstRequest from the same package checks the pair:

$ node --input-type=module -e '
import { readFileSync } from "node:fs";
import { validateResponseAgainstRequest } from "rtblint-core";

const [response, request] = process.argv.slice(1).map((f) => readFileSync(f, "utf8"));
const report = validateResponseAgainstRequest(response, request, "2.6-202606");
console.log(JSON.stringify(report, null, 2));
process.exit(report.valid ? 0 : 1);
' response.json sample-request.json
{
  "valid": true,
  "issues": []
}
$ echo $?
0

To see what the pair check catches, break the bid by declaring video markup on a banner impression:

$ jq '.seatbid[0].bid[0].mtype = 2' response.json > bad.json
# the same node command, with bad.json in place of response.json
{
  "valid": false,
  "issues": [
    {
      "id": "openrtb.bid.adm.vast_root_missing",
      "severity": "warning",
      "message": "mtype 2 declares video markup (VAST XML), but adm has no VAST or DAAST document root.",
      "path": "seatbid[0].bid[0].adm",
      "section": "4.2.3"
    },
    {
      "id": "openrtb.bid.mtype_not_offered",
      "severity": "error",
      "message": "mtype 2 declares video markup, but imp \"1\" does not offer a video subtype.",
      "path": "seatbid[0].bid[0].mtype",
      "section": "4.2.3"
    }
  ]
}
$ echo $?
1

The error is openrtb.bid.mtype_not_offered, and the warning is openrtb.bid.adm.vast_root_missing. A single-payload check would pass the mtype error, because 2 is a legal value on its own.

Check the other paths

Each branch of the handler has a request that reaches it:

# 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

Send a win notice

Play the exchange: take the nurl from the saved response, substitute the macros, and call it.

$ 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 log now holds one line per event from this session:

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

The macro expander does the same substitution in the browser for any notice URL.

What this bidder leaves out

The tutorial stops where the protocol work ends. Before real traffic, a bidder also needs:

  • Bids on more than one impression, and native and audio support. Each extra format adds a match rule in pickImp and an mtype value.
  • Deals. Bids on imp.pmp deals carry Bid.dealid, and the deals guide explains the rules.
  • Privacy handling. Read regs.gpp, regs.coppa, and the consent fields before using user or device identifiers.
  • Operations: gzip on responses when the exchange sends accept-encoding: gzip, keep-alive tuning, metrics on bid rate and timeouts, and loss notices through lurl.

The Rust version of this bidder is in Build a minimal OpenRTB bidder in Rust. To keep either one correct as it grows, replay recorded requests against it on every build, as shown in Test an OpenRTB bidder with fixtures in CI. To inspect a single payload by hand, paste it into the bid request tester.

Questions people ask

Should a bidder answer a no-bid with 204 or with a BidResponse?

Either is allowed. An empty HTTP 204 is the plain no-bid. A BidResponse with only id and nbr also says no, and carries a reason code. This bidder sends 204 when it simply passes and nbr 2 (Invalid Request) when the request fails validation, so the exchange can see why.

How much of tmax can a bidder spend on computation?

Less than all of it. tmax counts from the exchange's side and includes the network round trip, so the bidder sets its own deadline at arrival time plus tmax minus a network reserve. The tutorial reserves 40 ms. Measure the real latency between you and each exchange and set the reserve from that.

Does validating every bid request slow the bidder down?

Not much. With rtblint-core 0.14.7 on Node 22, 2,000 validations of the sample request averaged 0.024 ms each on an Apple Silicon Mac. The first request after a cold start was slower, about 9 ms before reaching the pricing step, so warm the process up before it takes traffic.