Build a Real-Time Bidder in Python: Step-by-Step RTB Tutorial

Expert guides, insights and articles updated for 2026

Published 3 hours ago

Build a Real-Time Bidder in Python: A Step-by-Step Guide

Real-time bidding often gets explained with diagrams but not enough working code. You hear terms like OpenRTB, bid request, seatbid, and win notice, yet the actual request-response loop stays abstract. That gap is where most of the confusion starts.

If you want to build a real-time bidder in Python, the key idea is simple: RTB is a latency-constrained decision loop. A request arrives, you inspect the impression opportunity, decide whether to buy it, and respond before the deadline runs out.

This guide walks through a minimal Python bidder you can run locally. It accepts an OpenRTB-style request, applies simple bidding rules, returns either a bid or a no-bid, and logs win notices.

This is a learning prototype, not a production DSP. It leaves out pacing, creative workflows, identity, attribution, and large-scale infrastructure. What it gives you instead is a working foundation you can inspect and extend.


The RTB Mental Model Before You Write Code

Four-stage RTB flowchart with bid request, decisioning engine, bid response or no-bid branch, and win notice feedback path.
Use this visual to anchor the mental model before code. It makes the request → decision → response → win notice sequence concrete and shows where no-bid fits as a valid outcome rather than an error.

Before opening Python, get the auction flow straight. Most implementation mistakes come from misunderstanding what the bidder must do under time pressure.

The four-stage flow: request → decision → response → win notice

A basic RTB interaction looks like this:

  1. An exchange sends your bidder a bid request
  2. Your bidder evaluates the request against targeting and pricing rules
  3. Your bidder returns either a bid response or no-bid
  4. If your bid wins, the exchange may send a win notice

That is the core loop.

A useful mental model is to treat each request as a tiny buying decision with an expiration timer attached. You are not browsing inventory. You are making a fast yes-or-no decision on one impression at a time.

Who does what

Actor Role What they send
Exchange / SSP Offers auction opportunities Bid request
Bidder Evaluates and bids Bid response or no-bid
Advertiser logic Defines goals and constraints Campaign rules, bid caps, targeting
Tracking / analytics Records outcomes Win, impression, click, conversion logs

In this tutorial, your Python app is the bidder. Advertiser logic is just a few hard-coded rules.

Why latency changes the design

In many systems, you can afford extra validation, a database lookup, or another API call. In RTB, those choices can make your bid miss the auction.

The tmax field in OpenRTB defines the exchange’s maximum response window.[^1] Even if tmax is 120 milliseconds, your app does not get all of it. Network overhead and exchange-side processing consume part of that budget.

Key Insight: In RTB, a correct answer that arrives late is effectively the same as no answer.

That is why a fast no-bid is often better than a slow bid.

Bottom Line: RTB is best understood as a deadline-driven decision engine, not just an auction format.


What a Minimal Bidder Needs to Do

A first bidder only needs to handle five responsibilities.

Accept an HTTP POST with a bid request

Your bidder needs an endpoint, usually POST /bid, that accepts JSON.

Parse the core OpenRTB fields

You do not need the full specification to get started. You only need enough data to answer one question:

Should I bid on this impression, and if so, how much?

Decide whether to bid

This is the core decision function. In a first version, use transparent rules rather than machine learning.

Construct a valid bid response

If the request qualifies, return JSON that references the right impression ID and includes price and creative details.

Handle win notifications and logging

A local /win endpoint is enough to show the feedback loop. Even simple logging teaches the right architecture.

Decision Rule: If your first bidder can receive, evaluate, respond, and record, it already covers the essential system boundaries.


OpenRTB Essentials: Start Small

Structured OpenRTB request breakdown highlighting request id, impression object, bid floor, site domain, device geo, and tmax fields within a JSON-style panel.
This image helps readers see which parts of an OpenRTB request matter first in a beginner build. It reduces schema overload by visually isolating the fields that drive eligibility, pricing, and timing decisions.

The OpenRTB spec is broad.[^1] A beginner build should focus on a small subset.

Bid request fields that matter first

Field Why it matters Notes
id Identifies the auction request Echo this in the response
imp Contains one or more impression opportunities Usually the most important object
imp[].id Identifies the impression Used as impid in the bid
imp[].bidfloor Minimum acceptable bid Do not bid below it
site or app Shows where the impression comes from This tutorial uses site
device Helps with targeting Device type, user agent, geo hints
user Optional user context Often sparse or privacy-limited
tmax Response deadline budget Critical for timeout logic

Bid response fields that matter

Field Why it matters Notes
id Ties response to request Usually set to request ID
seatbid Holds bids Top-level array
seatbid[].bid Actual bid list At least one bid to participate
bid.id Unique bid ID Can be generated locally
bid.impid Ties bid to impression Must match imp[].id
bid.price Bid amount Must respect floor
bid.adm Ad markup Placeholder only in this demo
bid.crid Creative ID Hard-coded is fine for a demo
bid.nurl Win notice URL in some integrations Exchange-specific; verify docs

How to think about imp

A request may contain multiple imp objects. Each one is a possible auction opportunity.

In production, you might evaluate several impressions with different pricing or creative logic. In this tutorial, we keep it simple and bid on the first compatible impression.

That is not a best practice for every case. It is just a useful simplification for learning.

When to skip fields instead of guessing

If a field is optional and your bidder does not need it, ignore it.

Guessing is worse than skipping. For example, if you do not know how an exchange expects adm or notification fields to behave, do not invent production assumptions. Use clearly labeled demo placeholders and check the partner documentation later.[^1]

Common Mistake: Trying to model the whole OpenRTB schema before building the first working request-response loop.

Bottom Line: Start with the fields that affect eligibility, pricing, and response validity. Add the rest later.


A Simple Framework: Receive, Evaluate, Respond, Record

To keep the system understandable, use this four-part framework throughout the build.

The framework

Stage What it does What can go wrong
Receive Accept JSON, validate shape, normalize fields Malformed JSON, missing imp, missing id
Evaluate Apply fast bidding rules Slow logic, bad targeting assumptions, ignored floors
Respond Return a compliant bid or no-bid Invalid JSON, wrong impid, late response
Record Log decisions and outcomes No debugging trail, no learning loop

This framework maps directly to how a real bidder behaves under load.

Receive

Receive, Evaluate, Respond, Record framework shown as four connected modules with inputs, decision rules, response output, and logging trail.
The value of this image is structural: it turns the article’s implementation framework into a system map. Readers can see how validation, bidding logic, response formatting, and logging stay separate so the bidder remains testable and extensible.

Validate just enough to avoid crashing or bidding blindly.

Evaluate

A first bidder should use rules you can explain easily. That makes testing and debugging far simpler.

Respond

A no-bid is a valid outcome, not a failure.

Record

Without logs, you cannot improve targeting, pricing, or win rate later.

Bottom Line: Keeping these four stages separate makes the bidder easier to test, debug, and extend.


Set Up the Python Project

For readability, this guide uses FastAPI[^2] and Uvicorn[^3].

Recommended stack

  • FastAPI for the HTTP API and JSON handling
  • Pydantic for lightweight typed validation
  • Uvicorn for running the app locally

You could use Flask, but FastAPI keeps typed request handling cleaner for this kind of tutorial.

Project structure

rtb-bidder/
├── app/
│   └── main.py
├── sample_payloads/
│   ├── bid_request_us_mobile.json
│   └── bid_request_nobid.json
└── requirements.txt

Install dependencies

Create requirements.txt:

fastapi
uvicorn
pydantic

Install them:

python -m venv venv
source venv/bin/activate   # Windows: venv\Scripts\activate
pip install -r requirements.txt

Setup checklist

  • Create the project folder
  • Install FastAPI, Uvicorn, and Pydantic
  • Add app/main.py
  • Add sample JSON payloads
  • Start the local server with Uvicorn

Checkpoint: If you can run a FastAPI app locally, you are ready to build the bidder.


Step 1: Create the Bid Request Endpoint

The first goal is modest: accept a request safely and fail cleanly.

Create app/main.py

import time
import uuid
import logging
from typing import Any, Dict, Optional

from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel


logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("rtb_bidder")

app = FastAPI(title="Minimal Python RTB Bidder")


ALLOWED_COUNTRIES = {"USA", "US"}
ALLOWED_PUBLISHERS = {"example.com", "news.example.com"}
MAX_BID_CPM = 1.35
MAX_ACCEPTABLE_FLOOR = 1.20
INTERNAL_TMAX_CAP_MS = 100


class BidDecision(BaseModel):
    should_bid: bool
    reason: str
    impid: Optional[str] = None
    price: Optional[float] = None
    creative_id: Optional[str] = None


def get_country(payload: Dict[str, Any]) -> Optional[str]:
    device = payload.get("device", {}) or {}
    geo = device.get("geo", {}) or {}
    return geo.get("country")


def get_domain(payload: Dict[str, Any]) -> Optional[str]:
    site = payload.get("site", {}) or {}
    return site.get("domain")


def is_mobile_web(payload: Dict[str, Any]) -> bool:
    site = payload.get("site")
    app_obj = payload.get("app")
    if not site or app_obj:
        return False

    device = payload.get("device", {}) or {}
    devicetype = device.get("devicetype")
    ua = (device.get("ua") or "").lower()

    mobile_by_type = devicetype in {1, 4, 5}
    mobile_by_ua = "iphone" in ua or "android" in ua or "mobile" in ua
    return mobile_by_type or mobile_by_ua


def choose_impression(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
    impressions = payload.get("imp", [])
    if not isinstance(impressions, list):
        return None

    for imp in impressions:
        if isinstance(imp, dict) and imp.get("id"):
            return imp
    return None

Why this structure works

This is not a full OpenRTB schema. It is minimal structural validation.

That matters because exchange payloads are often sparse, and different integrations add different extensions.

Return no-bid when essentials are missing

Now add the evaluation logic:

def evaluate_request(payload: Dict[str, Any], deadline_ms: float) -> BidDecision:
    now_ms = time.time() * 1000
    if now_ms >= deadline_ms:
        return BidDecision(should_bid=False, reason="timeout_before_evaluation")

    request_id = payload.get("id")
    if not request_id:
        return BidDecision(should_bid=False, reason="missing_request_id")

    imp = choose_impression(payload)
    if not imp:
        return BidDecision(should_bid=False, reason="missing_impression")

    impid = imp.get("id")
    bidfloor = float(imp.get("bidfloor", 0.0) or 0.0)
    country = get_country(payload)
    domain = get_domain(payload)

    if country not in ALLOWED_COUNTRIES:
        return BidDecision(should_bid=False, reason="geo_not_allowed")

    if not is_mobile_web(payload):
        return BidDecision(should_bid=False, reason="not_mobile_web")

    if domain not in ALLOWED_PUBLISHERS:
        return BidDecision(should_bid=False, reason="publisher_not_allowed")

    if bidfloor > MAX_ACCEPTABLE_FLOOR:
        return BidDecision(should_bid=False, reason="floor_too_high")

    now_ms = time.time() * 1000
    if now_ms >= deadline_ms:
        return BidDecision(should_bid=False, reason="timeout_after_filters")

    bid_price = max(bidfloor, MAX_BID_CPM)

    return BidDecision(
        should_bid=True,
        reason="eligible",
        impid=impid,
        price=round(bid_price, 2),
        creative_id="creative-001"
    )

Decision Rule: If the request is malformed or missing essentials, return no-bid quickly instead of trying to infer meaning.


Step 2: Parse a Minimal Bid Request

Now make the parsing logic explicit.

Extract the fields that drive the decision

The most important parts of the request are:

  • id
  • imp[].id
  • imp[].bidfloor
  • site.domain
  • device.ua
  • device.devicetype
  • device.geo.country
  • tmax

That is enough for a first policy such as:

“Bid only on US mobile web traffic from approved publishers if the floor is acceptable.”

Handle multiple impressions simply

This prototype uses choose_impression() to pick the first valid impression.

That is a deliberate simplification. Real implementations may evaluate several impressions or return multiple bids depending on the integration.

Normalize optional fields safely

Notice how the helper functions default to empty dicts. That keeps the bidder from crashing when site, device, or geo is missing.

A common beginner mistake is assuming every request has the same shape. In RTB, sparse payloads are normal.

Bottom Line: Defensive parsing matters more than complete parsing in a first bidder.


Step 3: Implement a Minimal Bidding Strategy

This is where many tutorials get too ambitious. Do not start with machine learning. Start with a rule you can explain in one sentence.

Use a rule-based strategy first

Our demo strategy is:

  • bid only on US mobile web
  • bid only on an approved publisher allowlist
  • bid only when bidfloor <= 1.20
  • bid 1.35 CPM when eligible

These values are illustrative, not market guidance.

Why these rules are useful

Imagine a startup advertiser that only wants mobile web visitors from a small set of known publishers because those placements have already been tested.

That is realistic. It is narrow enough to debug and selective enough to make no-bid decisions common.

Respect floors and cap spend

If the floor is 1.50 and your max acceptable floor is 1.20, do not bid.

If the floor is 0.80, the demo bidder can return 1.35.

This works because:

  • the floor check protects spend discipline
  • the allowlist protects inventory quality
  • geo and device filters keep traffic aligned with campaign intent

When no-bid is the right answer

Return no-bid when:

  • the country is unsupported
  • the publisher is not allowed
  • the traffic is not mobile web
  • the floor is too high
  • required fields are missing
  • there is not enough time left to respond

Key Insight: A healthy bidder is selective. Most opportunities should be rejected quickly if they do not fit the strategy.


Step 4: Build the Bid Response

Now turn an eligible decision into a valid response.

Add the response builder

def build_bid_response(request_id: str, decision: BidDecision) -> Dict[str, Any]:
    return {
        "id": request_id,
        "seatbid": [
            {
                "seat": "demo-seat",
                "bid": [
                    {
                        "id": str(uuid.uuid4()),
                        "impid": decision.impid,
                        "price": decision.price,
                        "crid": decision.creative_id,
                        "adm": "<div>Demo Creative</div>",
                        "nurl": f"http://localhost:8000/win?impid={decision.impid}&price=${{AUCTION_PRICE}}"
                    }
                ]
            }
        ]
    }

A few practical notes

  • price should be at or above the floor
  • crid can be hard-coded in a demo
  • adm is only a placeholder here
  • nurl varies by exchange and OpenRTB version, so treat it as illustrative and verify integration docs before production[^1]

For display traffic, markup and macros can vary a lot across exchanges and media types. This example teaches response structure, not creative compliance.

Wire the endpoint together

@app.post("/bid")
async def bid_endpoint(request: Request):
    start_ms = time.time() * 1000

    try:
        payload = await request.json()
    except Exception:
        logger.info("invalid_json")
        return Response(status_code=204)

    tmax = payload.get("tmax", INTERNAL_TMAX_CAP_MS)
    try:
        tmax = int(tmax)
    except Exception:
        tmax = INTERNAL_TMAX_CAP_MS

    effective_tmax = min(tmax, INTERNAL_TMAX_CAP_MS)
    deadline_ms = start_ms + effective_tmax

    decision = evaluate_request(payload, deadline_ms)
    logger.info(
        "request_id=%s should_bid=%s reason=%s",
        payload.get("id"),
        decision.should_bid,
        decision.reason
    )

    if not decision.should_bid:
        # Some exchanges prefer 204 No Content for no-bid.
        # Verify your partner's expected behavior.
        return Response(status_code=204)

    response_payload = build_bid_response(payload["id"], decision)
    return JSONResponse(content=response_payload)

Common Mistake: Returning malformed JSON, using the wrong impid, or bidding below the floor. Any one of those can get the bid rejected.

Checkpoint: Your bidder now has a complete request → decision → response path.


Step 5: Add Timeout Awareness

A bidder that ignores time budgets is not really an RTB bidder.

Understand tmax

tmax is the maximum response time budget sent in the request.[^1] It is not a suggestion.

In practice, your internal deadline should be lower than the full tmax, because the total round trip includes more than your Python code.

Design for fast decisions

This tutorial uses:

  • a small internal cap: INTERNAL_TMAX_CAP_MS = 100
  • in-memory rules
  • no database calls
  • no remote services
  • no large models

That is the right shape for a first prototype.

What to do when time is almost gone

Do not try to rescue a request that is already timing out. Return no-bid.

A late bid wastes resources and creates misleading logs. You think you participated, but the exchange may never have considered the bid.

Decision Rule: If you are close to the deadline and still uncertain, no-bid is the safer choice.


Step 6: Implement a Win Notice Endpoint

Winning matters because it closes the loop.

Why win notices matter

If your bid wins, some integrations send a callback or fire a notification URL. That tells your bidder which bids actually cleared the auction.

Log wins in the prototype

In production, you would store win events for optimization, reconciliation, and budget tracking.

For a local prototype, logging is enough:

@app.get("/win")
async def win_notice(impid: Optional[str] = None, price: Optional[str] = None):
    logger.info("win_notice impid=%s price=%s", impid, price)
    return {"status": "logged", "impid": impid, "price": price}

This is simple, but it teaches the right concept: your bidder should not stop caring after it sends a bid.

Bottom Line: Bid submission is not the end of the system. Outcome logging is part of the architecture.


Full Example: Minimal RTB Bidder in Python

import time
import uuid
import logging
from typing import Any, Dict, Optional

from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("rtb_bidder")

app = FastAPI(title="Minimal Python RTB Bidder")

ALLOWED_COUNTRIES = {"USA", "US"}
ALLOWED_PUBLISHERS = {"example.com", "news.example.com"}
MAX_BID_CPM = 1.35
MAX_ACCEPTABLE_FLOOR = 1.20
INTERNAL_TMAX_CAP_MS = 100


class BidDecision(BaseModel):
    should_bid: bool
    reason: str
    impid: Optional[str] = None
    price: Optional[float] = None
    creative_id: Optional[str] = None


def get_country(payload: Dict[str, Any]) -> Optional[str]:
    device = payload.get("device", {}) or {}
    geo = device.get("geo", {}) or {}
    return geo.get("country")


def get_domain(payload: Dict[str, Any]) -> Optional[str]:
    site = payload.get("site", {}) or {}
    return site.get("domain")


def is_mobile_web(payload: Dict[str, Any]) -> bool:
    site = payload.get("site")
    app_obj = payload.get("app")
    if not site or app_obj:
        return False

    device = payload.get("device", {}) or {}
    devicetype = device.get("devicetype")
    ua = (device.get("ua") or "").lower()

    mobile_by_type = devicetype in {1, 4, 5}
    mobile_by_ua = "iphone" in ua or "android" in ua or "mobile" in ua
    return mobile_by_type or mobile_by_ua


def choose_impression(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
    impressions = payload.get("imp", [])
    if not isinstance(impressions, list):
        return None

    for imp in impressions:
        if isinstance(imp, dict) and imp.get("id"):
            return imp
    return None


def evaluate_request(payload: Dict[str, Any], deadline_ms: float) -> BidDecision:
    now_ms = time.time() * 1000
    if now_ms >= deadline_ms:
        return BidDecision(should_bid=False, reason="timeout_before_evaluation")

    request_id = payload.get("id")
    if not request_id:
        return BidDecision(should_bid=False, reason="missing_request_id")

    imp = choose_impression(payload)
    if not imp:
        return BidDecision(should_bid=False, reason="missing_impression")

    impid = imp.get("id")
    bidfloor = float(imp.get("bidfloor", 0.0) or 0.0)
    country = get_country(payload)
    domain = get_domain(payload)

    if country not in ALLOWED_COUNTRIES:
        return BidDecision(should_bid=False, reason="geo_not_allowed")

    if not is_mobile_web(payload):
        return BidDecision(should_bid=False, reason="not_mobile_web")

    if domain not in ALLOWED_PUBLISHERS:
        return BidDecision(should_bid=False, reason="publisher_not_allowed")

    if bidfloor > MAX_ACCEPTABLE_FLOOR:
        return BidDecision(should_bid=False, reason="floor_too_high")

    now_ms = time.time() * 1000
    if now_ms >= deadline_ms:
        return BidDecision(should_bid=False, reason="timeout_after_filters")

    bid_price = max(bidfloor, MAX_BID_CPM)

    return BidDecision(
        should_bid=True,
        reason="eligible",
        impid=impid,
        price=round(bid_price, 2),
        creative_id="creative-001"
    )


def build_bid_response(request_id: str, decision: BidDecision) -> Dict[str, Any]:
    return {
        "id": request_id,
        "seatbid": [
            {
                "seat": "demo-seat",
                "bid": [
                    {
                        "id": str(uuid.uuid4()),
                        "impid": decision.impid,
                        "price": decision.price,
                        "crid": decision.creative_id,
                        "adm": "<div>Demo Creative</div>",
                        "nurl": f"http://localhost:8000/win?impid={decision.impid}&price=${{AUCTION_PRICE}}"
                    }
                ]
            }
        ]
    }


@app.post("/bid")
async def bid_endpoint(request: Request):
    start_ms = time.time() * 1000

    try:
        payload = await request.json()
    except Exception:
        logger.info("invalid_json")
        return Response(status_code=204)

    tmax = payload.get("tmax", INTERNAL_TMAX_CAP_MS)
    try:
        tmax = int(tmax)
    except Exception:
        tmax = INTERNAL_TMAX_CAP_MS

    effective_tmax = min(tmax, INTERNAL_TMAX_CAP_MS)
    deadline_ms = start_ms + effective_tmax

    decision = evaluate_request(payload, deadline_ms)
    logger.info(
        "request_id=%s should_bid=%s reason=%s",
        payload.get("id"),
        decision.should_bid,
        decision.reason
    )

    if not decision.should_bid:
        return Response(status_code=204)

    response_payload = build_bid_response(payload["id"], decision)
    return JSONResponse(content=response_payload)


@app.get("/win")
async def win_notice(impid: Optional[str] = None, price: Optional[str] = None):
    logger.info("win_notice impid=%s price=%s", impid, price)
    return {"status": "logged", "impid": impid, "price": price}

What each function does

  • get_country() extracts country from device.geo
  • get_domain() reads the publisher domain from site
  • is_mobile_web() distinguishes mobile web from app or desktop
  • choose_impression() selects a compatible impression
  • evaluate_request() applies bidding rules
  • build_bid_response() formats a minimal response
  • /bid handles the auction request
  • /win logs win notices

Sample Payloads

Sample bid request

Save as sample_payloads/bid_request_us_mobile.json:

{
  "id": "req-1001",
  "imp": [
    {
      "id": "imp-1",
      "bidfloor": 0.8
    }
  ],
  "site": {
    "domain": "example.com",
    "page": "https://example.com/article/123"
  },
  "device": {
    "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148",
    "devicetype": 4,
    "geo": {
      "country": "USA"
    }
  },
  "tmax": 120
}

Sample bid response

{
  "id": "req-1001",
  "seatbid": [
    {
      "seat": "demo-seat",
      "bid": [
        {
          "id": "f3d2e86c-4f6c-4e8a-a6f8-11f7f0f2bdb2",
          "impid": "imp-1",
          "price": 1.35,
          "crid": "creative-001",
          "adm": "<div>Demo Creative</div>",
          "nurl": "http://localhost:8000/win?impid=imp-1&price=${AUCTION_PRICE}"
        }
      ]
    }
  ]
}

Sample no-bid request

Save as sample_payloads/bid_request_nobid.json:

{
  "id": "req-1002",
  "imp": [
    {
      "id": "imp-2",
      "bidfloor": 1.5
    }
  ],
  "site": {
    "domain": "example.com",
    "page": "https://example.com/article/999"
  },
  "device": {
    "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148",
    "devicetype": 4,
    "geo": {
      "country": "USA"
    }
  },
  "tmax": 120
}

This one should return no-bid because the floor is above the configured threshold.


How to Test the Bidder Locally

Start the server

uvicorn app.main:app --reload

Test a bid case

curl -X POST "http://127.0.0.1:8000/bid" \
  -H "Content-Type: application/json" \
  --data @sample_payloads/bid_request_us_mobile.json

Test a no-bid case

curl -i -X POST "http://127.0.0.1:8000/bid" \
  -H "Content-Type: application/json" \
  --data @sample_payloads/bid_request_nobid.json

Expected outcomes

Scenario Expected result Why
US mobile web, allowed domain, floor 0.8 Bid response Meets all rules
US mobile web, allowed domain, floor 1.5 No-bid Floor too high
Canada mobile web No-bid Geo not allowed
Desktop traffic No-bid Not mobile web
Unknown publisher No-bid Not in allowlist

Debugging checklist

  • Content-Type: application/json is set
  • Request has id
  • imp is present and is a list
  • imp[].id exists
  • Response impid matches the request impression
  • Bid price is not below bidfloor
  • JSON is valid
  • Code does not assume missing fields exist

Common Mistake: Treating a 204 no-bid as an error. In many integrations, that is the normal and preferred no-bid signal. Always confirm partner docs.


Common Mistakes in a First RTB Bidder

Ignoring latency until late in the build

If the design depends on slow calls in the hot path, it will eventually miss auctions.

Assuming every request has the same structure

OpenRTB payloads are often sparse. Your parser needs to tolerate missing optional fields.

Returning invalid bid objects

A valid strategy with an invalid response is still a failed bid.

Bidding below floor or on unsupported inventory

A bidder should reject impossible or unqualified auctions immediately.

Treating the demo as production-ready

This tutorial is intentionally educational. A real bidder needs much more than a clean local API.

Bottom Line: The fastest path to a useful first bidder is disciplined simplification.


What This Prototype Does Not Solve

A working local bidder is useful, but still far from a production traffic-buying system.

Missing production concerns

  • Budget pacing and spend controls
  • Creative approval and rendering rules
  • User identity, frequency capping, and attribution
  • Model-based bidding and win-rate optimization
  • Security, scaling, and observability

What production systems usually need

  • Metrics and tracing
  • Structured event logs
  • Rate limiting and abuse protection
  • Resilience under load
  • Exchange-specific validation
  • Integration testing
  • Secure configuration management

Decision Rule: If your goal is learning or controlled experimentation, this prototype is enough. If your goal is dependable spend at scale, it is not.


Build vs. Buy: When a Custom Bidder Makes Sense

Not every advertiser or startup should build RTB infrastructure.

Good reasons to build

Reason Why it makes sense
You want to learn RTB deeply Building exposes the real mechanics
You need custom optimization logic Managed tools may be too opinionated
You are testing a niche traffic strategy A custom prototype can validate assumptions quickly
You need full control over auction decisions Internal logic stays transparent

When managed infrastructure is the better choice

Reason Why it often wins
You need speed to market Infrastructure already exists
You do not want to maintain bidder operations Lower engineering burden
You need compliance and operational support Managed providers handle more edge cases
You care more about outcomes than system ownership Better fit for most advertisers

Where Traffics.io can fit

If your real goal is not to run bidding infrastructure but to buy traffic efficiently, managed services may be the better option.

For advertisers and business owners who want acquisition outcomes without maintaining their own RTB stack, Traffics.io can be a practical fit. That is especially true when the cost of building, maintaining, and integrating a bidder outweighs the value of owning the system.

Bottom Line: Build when learning or custom logic is the priority. Buy when reliability, speed, and operational simplicity matter more.


Next Steps

Once the first version works, do not add everything at once. Add the next constraint that matters.

Add campaign rules and targeting layers

Move from hard-coded constants to campaign configuration.

Examples:

  • per-publisher bid caps
  • separate geo rules
  • device-specific pricing
  • blocklists and allowlists

Store win and loss events

Even simple persistence changes the system. Once you record outcomes, you can evaluate:

  • which publishers win most often
  • where floor filters reject too much inventory
  • which bid levels are competitive

Introduce pacing and budget management

This is where a bidder starts acting like a buying system instead of a request parser.

Move from fixed rules to predictive bidding

Only after you have data should you consider models for:

  • estimated click probability
  • conversion likelihood
  • dynamic pricing
  • win-rate-adjusted bids

Practical Next Step: Refactor the constants into a config file or campaign object before adding more logic. That one change will make future growth much easier.


Conclusion

The main takeaway is simpler than the jargon suggests: a bidder receives an auction opportunity, evaluates it under a deadline, responds with a bid or no-bid, and records the outcome.

You now have a working foundation for a real-time bidder in Python. More importantly, you should understand why each part exists: imp represents the opportunity, bidfloor shapes pricing, tmax shapes architecture, no-bid is normal, and win notices close the loop.

From here, the most useful next steps are campaign configuration, event storage, pacing, and richer targeting. Once those are in place, you are no longer just returning JSON. You are starting to build real traffic-buying logic.


FAQ

What is a real-time bidder in Python?

A real-time bidder in Python is an application that receives ad auction requests, evaluates them under strict time limits, and returns either a bid or a no-bid. In practice, it usually processes OpenRTB bid requests over HTTP and applies simple or advanced bidding logic.

Do I need the full OpenRTB specification to build a basic bidder?

No. You can start with a small subset of fields, including request ID, impression data, bid floor, site or app context, device details, and tmax. The full specification matters later when you handle more inventory types, exchange-specific extensions, and production requirements.

Which fields matter most in a minimal bid request?

A useful starting set is id, imp, imp[].id, imp[].bidfloor, site or app, device, geo when available, and tmax. That is usually enough to identify the auction, inspect the opportunity, apply a simple strategy, and decide whether to bid.

What does no-bid mean in RTB?

No-bid means the bidder intentionally chooses not to participate in that auction. This is normal behavior. A bidder should return no-bid quickly when the traffic does not match targeting rules, the floor is too high, required fields are missing, or there is not enough time left to respond safely.

Why is tmax important?

tmax defines the maximum response time budget the exchange allows. It matters because RTB is latency-sensitive. If your bidder responds too slowly, the exchange may ignore the bid even if your logic was correct.

Can I build a production DSP from this tutorial?

No. This is a learning prototype, not a production DSP. It can teach the request-response loop, parsing, response structure, and basic strategy design, but production systems also need pacing, budgets, creative compliance, identity handling, attribution, observability, scaling, fraud controls, and exchange-specific certification.

What is the simplest bidding strategy for a first prototype?

A good first strategy is rule-based and deterministic. For example, bid only on US mobile web traffic from an approved publisher allowlist when the bid floor is below a configured maximum CPM. That keeps the logic easy to test and improve.

How do I test a Python bidder locally?

Run the bidder with FastAPI and Uvicorn, then send sample OpenRTB JSON payloads with curl or Postman to a local /bid endpoint. Test both a bid scenario and a no-bid scenario so you can confirm parsing, floor checks, response formatting, and timeout handling.

What should a minimal bid response include?

At minimum, a learning example should include the request ID, a seatbid array, and a bid object with bid ID, impression ID, price, creative ID, and any demo markup or notification fields used in the example. Exact requirements vary by exchange and media type, so production integrations should follow official documentation.

When should I build my own bidder instead of using managed infrastructure?

Building your own bidder makes sense when you need custom experimentation, unique optimization logic, or a deeper understanding of programmatic systems. Managed infrastructure is usually the better choice when speed, operational support, compliance, and reduced engineering burden matter more than full control.

[^1]: OpenRTB behavior and field usage can vary by version and exchange implementation. Always verify against official partner documentation. [^2]: FastAPI documentation: https://fastapi.tiangolo.com/ [^3]: Uvicorn documentation: https://www.uvicorn.org/

real-time bidding, OpenRTB, Python, programmatic advertising, RTB tutorial, ad tech, traffic buying automation, FastAPI, DSP architecture, bidder development

Would you like to contribute content to this article? Contact us today!


No comments yet. Be the first to comment on this article!