Designing a Custom Prebid Server Adapter in Go: Concurrency, Timeout Handling, and JSON Schema Validation

When Prebid Server Go (prebid-server) ingests an inbound /openrtb2/auction payload, the core exchange engine isolates every downstream demand partner inside

Dark-mode 3D systems-programming artwork of a central cyan hexagonal Go processor splitting an incoming fiber-optic cable into six parallel light-blue goroutine threads that converge at a golden output port.

When Prebid Server Go (prebid-server) ingests an inbound /openrtb2/auction payload, the core exchange engine isolates every downstream demand partner inside an independent goroutine governed by a strict context.WithTimeout deadline, expecting the adapter layer to transform and validate OpenRTB 2.6 structs without mutating shared memory pointers. At high query-per-second (QPS) volumes across Connected TV (CTV), mobile app, and web supply, a single pointer alias bug inside an adapter's request splitter or an unbounded JSON unmarshal allocation can trigger race detector panics, garbage collection pauses, or premature context cancellation across the entire auction thread pool.

Building a custom server-side bidder adapter in Go differs fundamentally from writing a client-side Prebid.js module. Client-side adapters execute inside single-threaded browser contexts where memory isolation is implicit and latency budgets stretch to 1000 milliseconds or more. Server-side adapters execute inside multi-tenant, high-concurrency Go processes where a single stateless adapters.Bidder instance processes tens of thousands of simultaneous auctions per second within tight 150-to-300 millisecond timeout windows. Every allocation, slice mutation, currency lookup, and media type classification directly impacts host CPU utilization and clearing yield.

At Sky Boosts, our engineering teams maintain custom adapter implementations across our managed Prebid Server infrastructure to connect curated direct and semi-direct demand with zero-IVT web, mobile app, and CTV supply. This architectural guide walks through the complete engineering lifecycle of a production-grade Prebid Server Go adapter: tracing the OpenRTB 2.6 request pipeline, implementing the stateless Builder and Bidder interfaces, avoiding concurrency traps during multi-impression request splitting, enforcing JSON Schema Draft-04 parameter validation, and passing the adapterstest verification suite.

Prebid Server Go (PBS-Go) Request Processing Pipeline (OpenRTB 2.6 Native)

Before writing a single line of adapter logic, engineers must understand how Prebid Server Go normalizes, validates, and routes an incoming auction payload before invoking adapter code. PBS-Go relies natively on the IAB OpenRTB 2.6 specification via the github.com/prebid/openrtb/v20/openrtb2 package. Unlike legacy server implementations that treated OpenRTB 2.5 as the default baseline and stored 2.6 fields inside untyped extension maps, modern PBS-Go parses first-class OpenRTB 2.6 objects (including imp.rwdd, source.schain, Regs.GPP, imp.video.podid, and bid.mtype) directly into typed Go structs at the HTTP handler boundary.

When an HTTP POST request hits /openrtb2/auction or /openrtb2/amp, the exchange pipeline executes five sequential preprocessing stages prior to calling any bidder adapter:

  1. Stored Request Resolution and Merging: If the request references server-side stored auction templates or impression-level templates (imp[].ext.prebid.storedrequest.id), PBS fetches the cached JSON fragments and merges them with the inbound payload. This stage is critical for CTV and mobile SDK integrations where client payloads remain lightweight and bidder waterfalls reside on the server.
  2. Privacy, Consent, and Activity Enforcement: The privacy enforcement engine evaluates TCF v2.2 strings, Global Privacy Platform (regs.gpp and regs.gpp_sid), US Privacy strings, and COPPA flags against the vendor's Global Vendor List (GVL) ID declared in the bidder's YAML configuration. If a bidder lacks legal basis for processing personal data, PBS scrubs device.ifa, user.id, user.buyeruid, and precise geolocation coordinates (device.geo.lat, device.geo.lon) on a bidder-specific copy of the request before the adapter ever sees the struct.
  3. JSON Schema Validation of Bidder Parameters: In a multi-bidder OpenRTB request, caller parameters for each participating SSP or DSP live inside request.imp[i].ext.prebid.bidder.{bidder_code}. PBS validates this raw JSON object against the compiled JSON Schema defined in static/bidder-params/{bidder}.json. If an impression fails schema validation, PBS strips that bidder from the impression's routing table and appends a non-fatal warning to ext.warnings rather than aborting the entire auction.
  4. Extension Relocation (imp[].ext.bidder): Once validation succeeds, the exchange engine clones the impression list for each active bidder and rewrites the impression extension so that the target adapter's specific parameters are relocated from request.imp[i].ext.prebid.bidder.{bidder} directly into request.imp[i].ext.bidder. This normalization guarantees that an adapter never needs to inspect other bidders' configurations or navigate the nested ext.prebid hierarchy directly.
  5. Concurrent Goroutine Fan-Out and HTTP Execution: Inside exchange/exchange.go, PBS spawns a dedicated goroutine for each validated bidder seat. The exchange invokes the adapter's MakeRequests() method synchronously within that bidder's goroutine to obtain a slice of *adapters.RequestData structs. PBS then dispatches those HTTP calls concurrently using its shared, connection-pooled http.Client governed by a context.Context derived from request.TMax. Crucially, adapters never execute http.Client.Do() themselves; they act as pure functional translators that convert OpenRTB structs into raw HTTP request payloads (MakeRequests) and parse raw HTTP responses back into typed bids (MakeBids).

Because PBS instantiates each adapter exactly once at server startup and shares that singleton across every auction goroutine, the adapter implementation must remain 100% stateless. Storing per-request state on the adapter struct itself will immediately introduce data races under concurrent load.

Implementing the adapters.Builder and adapters.Bidder Interfaces

According to the official Prebid Server Go adapter development specification, every adapter package (adapters/{bidder}/{bidder}.go) must export an adapters.Builder function and implement the adapters.Bidder interface (MakeRequests and MakeBids) operating on OpenRTB 2.6, define a JSON Schema in static/bidder-params/{bidder}.json (which PBS relocates to request.imp[].ext.bidder), and register the bidder in openrtb_ext/bidders.go and exchange/adapter_builders.go.

Dark-mode software flow diagram of a Prebid Server Go adapter splitting an OpenRTB 2.6 JSON request via MakeRequests into parallel goroutines and normalizing bids in MakeBids before a context.WithTimeout deadline.

The adapters.Builder function serves as the constructor called during server initialization when PBS reads its YAML configuration and constructs the active bidder registry. Its signature accepts the bidder's registered name, the host platform's configuration entry (config.Adapter), and a config.Server struct containing host-level metadata such as the external URL and datacenter region. If your downstream endpoint uses dynamic macro substitution (such as injecting an account ID or regional host prefix into the URL), you parse and validate the URL text/template inside Builder at startup rather than compiling templates on every auction request.

The adapters.Bidder interface itself requires only two methods:

package skyboost

import (
	"encoding/json"
	"fmt"
	"net/http"

	"github.com/prebid/openrtb/v20/openrtb2"
	"github.com/prebid/prebid-server/v3/adapters"
	"github.com/prebid/prebid-server/v3/config"
	"github.com/prebid/prebid-server/v3/errortypes"
	"github.com/prebid/prebid-server/v3/openrtb_ext"
)

type adapter struct {
	endpoint string
}

type ExtImpSkyBoost struct {
	PlacementID string  `json:"placementId"`
	PublisherID string  `json:"publisherId"`
	BidFloor    float64 `json:"bidFloor,omitempty"`
}

func Builder(bidderName openrtb_ext.BidderName, endpointConfig config.Adapter, server config.Server) (adapters.Bidder, error) {
	if endpointConfig.Endpoint == "" {
		return nil, &errortypes.BadInput{
			Message: fmt.Sprintf("missing endpoint configuration for bidder %s", bidderName),
		}
	}
	return &adapter{
		endpoint: endpointConfig.Endpoint,
	}, nil
}

Notice how adapter holds only the immutable endpoint string configured at boot time. When PBS calls MakeRequests(request *openrtb2.BidRequest, reqInfo *adapters.ExtraRequestInfo) and MakeBids(internalRequest *openrtb2.BidRequest, externalRequest *adapters.RequestData, response *adapters.ResponseData), all request-scoped data enters via function arguments and exits via return slices.

MakeRequests(): Splitting Multi-Imp/Multi-Format OpenRTB Requests vs. Native Support

The primary architectural decision inside MakeRequests() is how your downstream demand endpoint handles multi-impression (len(request.Imp) > 1) and multi-format (imp.Banner != nil && imp.Video != nil) OpenRTB 2.6 requests. Demand endpoints fall into two categories:

  1. Native Multi-Impression Endpoints: The downstream DSP or SSP ingests a single OpenRTB 2.6 BidRequest containing multiple Imp objects and returns a consolidated BidResponse. This topology minimizes outbound HTTP socket exhaustion and TLS handshake overhead.
  2. Single-Impression Split Endpoints: Legacy or specialized endpoints require one HTTP POST per Imp object (or one HTTP POST per publisher account ID). In this model, an auction with 6 ad slots generates 6 elements in the []*adapters.RequestData slice returned by MakeRequests().

When splitting an openrtb2.BidRequest across multiple outbound HTTP calls, Go engineers must guard carefully against shallow-copy pointer aliasing. The *openrtb2.BidRequest passed into MakeRequests() contains reference types: slices (request.Imp, request.Cur, request.WSeat), pointer structs (request.App, request.Site, request.Device, request.User), and raw byte slices (json.RawMessage inside .Ext fields). If your loop mutates request.Imp or modifies request.App.Publisher.ID in place without shallow-copying the top-level BidRequest and deep-copying any mutated nested pointers, subsequent iterations (or diagnostic hooks inspecting internalRequest inside MakeBids) will read corrupted memory.

Furthermore, adapters should leverage reqInfo.ConvertCurrency() whenever an impression or bidder parameter specifies a floor in a currency other than USD (or the downstream endpoint's required clearing currency). PBS maintains live currency conversion rates fetched asynchronously in the background; calling reqInfo.ConvertCurrency(imp.BidFloor, imp.BidFloorCur, "USD") executes an in-memory lookup with zero network latency. This is especially crucial for mobile app publishers scaling global yield through our in-app bidding guide, where SDK requests frequently originate in EUR, GBP, or JPY while downstream DSPs clear exclusively in USD.

Below is a production-safe implementation of MakeRequests() that validates bidder extensions, normalizes floor currencies, performs safe copy isolation per impression, and sets the required OpenRTB 2.6 HTTP headers:

func (a *adapter) MakeRequests(request *openrtb2.BidRequest, reqInfo *adapters.ExtraRequestInfo) ([]*adapters.RequestData, []error) {
	var requests []*adapters.RequestData
	var errs []error

	headers := http.Header{}
	headers.Add("Content-Type", "application/json;charset=utf-8")
	headers.Add("Accept", "application/json")
	headers.Add("x-openrtb-version", "2.6")

	for _, imp := range request.Imp {
		var bidderExt adapters.ExtImpBidder
		if err := json.Unmarshal(imp.Ext, &bidderExt); err != nil {
			errs = append(errs, &errortypes.BadInput{
				Message: fmt.Sprintf("imp[%s]: unable to unmarshal ext: %v", imp.ID, err),
			})
			continue
		}

		var skyExt ExtImpSkyBoost
		if err := json.Unmarshal(bidderExt.Bidder, &skyExt); err != nil {
			errs = append(errs, &errortypes.BadInput{
				Message: fmt.Sprintf("imp[%s]: unable to unmarshal skyboost params: %v", imp.ID, err),
			})
			continue
		}

		impCopy := imp
		if skyExt.BidFloor > impCopy.BidFloor {
			impCopy.BidFloor = skyExt.BidFloor
			if impCopy.BidFloorCur == "" {
				impCopy.BidFloorCur = "USD"
			}
		}

		if impCopy.BidFloor > 0 && impCopy.BidFloorCur != "" && impCopy.BidFloorCur != "USD" {
			convertedFloor, err := reqInfo.ConvertCurrency(impCopy.BidFloor, impCopy.BidFloorCur, "USD")
			if err != nil {
				errs = append(errs, err)
				continue
			}
			impCopy.BidFloor = convertedFloor
			impCopy.BidFloorCur = "USD"
		}

		reqCopy := *request
		reqCopy.Imp = []openrtb2.Imp{impCopy}

		payload, err := json.Marshal(&reqCopy)
		if err != nil {
			errs = append(errs, err)
			continue
		}

		requests = append(requests, &adapters.RequestData{
			Method:  http.MethodPost,
			Uri:     a.endpoint,
			Body:    payload,
			Headers: headers,
			ImpIDs:  openrtb_ext.GetImpIDs(reqCopy.Imp),
		})
	}

	return requests, errs
}

Note two subtle details in MakeRequests(). First, populating ImpIDs: openrtb_ext.GetImpIDs(reqCopy.Imp) on every adapters.RequestData struct is mandatory in recent PBS-Go versions so the exchange metrics engine can attribute timeouts and HTTP errors to specific impressions. Second, when an impression contains invalid JSON in imp.Ext, the loop appends an &errortypes.BadInput error and continues processing remaining valid impressions rather than discarding the entire auction call.

MakeBids(): Parsing Responses and Mapping BidType

Once the exchange HTTP client completes the outbound network call (or terminates early if context.DeadlineExceeded triggers), PBS invokes MakeBids() for each completed HTTP transaction. If the request timed out at the transport layer, PBS handles the timeout telemetry automatically and skips calling MakeBids() for that timed-out request. For completed HTTP responses, MakeBids() must inspect the HTTP status code, unmarshal the OpenRTB 2.6 BidResponse, classify each bid's media type (openrtb_ext.BidType), and construct an *adapters.BidderResponse.

Status code classification directly impacts how PBS logs operational health metrics and circuit-breaker alerts:

  • http.StatusNoContent (204): A standard no-bid response. Return nil, nil immediately without allocating a BidderResponse or logging an error.
  • http.StatusBadRequest (400): Indicates a malformed client payload. Return an &errortypes.BadInput error so PBS suppresses server-side pager alerts while surfacing the error in debug traces (test: 1).
  • Any status other than http.StatusOK (200): Return &errortypes.BadServerResponse to increment the downstream bidder error counter in Prometheus/StatsD metrics.

When HTTP 200 is returned, the adapter unmarshals response.Body into openrtb2.BidResponse and maps each openrtb2.Bid into an adapters.TypedBid. In OpenRTB 2.6, DSPs should explicitly declare the creative format using the integer field bid.MType (1 for Banner, 2 for Video, 3 for Audio, 4 for Native). However, many demand endpoints still omit bid.MType. A resilient adapter inspects bid.MType first and falls back to matching bid.ImpID against the original internalRequest.Imp slice to infer the media type:

func (a *adapter) MakeBids(internalRequest *openrtb2.BidRequest, externalRequest *adapters.RequestData, response *adapters.ResponseData) (*adapters.BidderResponse, []error) {
	if response.StatusCode == http.StatusNoContent {
		return nil, nil
	}
	if response.StatusCode == http.StatusBadRequest {
		return nil, []error{&errortypes.BadInput{
			Message: fmt.Sprintf("unexpected status code: %d", response.StatusCode),
		}}
	}
	if response.StatusCode != http.StatusOK {
		return nil, []error{&errortypes.BadServerResponse{
			Message: fmt.Sprintf("unexpected status code: %d", response.StatusCode),
		}}
	}

	var bidResp openrtb2.BidResponse
	if err := json.Unmarshal(response.Body, &bidResp); err != nil {
		return nil, []error{&errortypes.BadServerResponse{
			Message: fmt.Sprintf("failed to parse response body: %v", err),
		}}
	}

	bidderResponse := adapters.NewBidderResponseWithBidsCapacity(len(externalRequest.ImpIDs))
	if bidResp.Cur != "" {
		bidderResponse.Currency = bidResp.Cur
	}

	var errs []error
	for _, seatBid := range bidResp.SeatBid {
		for i := range seatBid.Bid {
			bid := seatBid.Bid[i]
			bidType, err := getMediaTypeForBid(bid, internalRequest.Imp)
			if err != nil {
				errs = append(errs, err)
				continue
			}

			typedBid := &adapters.TypedBid{
				Bid:     &bid,
				BidType: bidType,
			}

			if bidType == openrtb_ext.BidTypeVideo {
				typedBid.BidVideo = &openrtb_ext.ExtBidPrebidVideo{
					Duration: int(bid.Dur),
				}
				if len(bid.Cat) > 0 {
					typedBid.BidVideo.PrimaryCategory = bid.Cat[0]
				}
			}

			bidderResponse.Bids = append(bidderResponse.Bids, typedBid)
		}
	}

	return bidderResponse, errs
}

func getMediaTypeForBid(bid openrtb2.Bid, imps []openrtb2.Imp) (openrtb_ext.BidType, error) {
	switch bid.MType {
	case openrtb2.MarkupBanner:
		return openrtb_ext.BidTypeBanner, nil
	case openrtb2.MarkupVideo:
		return openrtb_ext.BidTypeVideo, nil
	case openrtb2.MarkupAudio:
		return openrtb_ext.BidTypeAudio, nil
	case openrtb2.MarkupNative:
		return openrtb_ext.BidTypeNative, nil
	}

	for _, imp := range imps {
		if imp.ID == bid.ImpID {
			if imp.Video != nil {
				return openrtb_ext.BidTypeVideo, nil
			}
			if imp.Banner != nil {
				return openrtb_ext.BidTypeBanner, nil
			}
			if imp.Native != nil {
				return openrtb_ext.BidTypeNative, nil
			}
			if imp.Audio != nil {
				return openrtb_ext.BidTypeAudio, nil
			}
		}
	}

	return "", &errortypes.BadServerResponse{
		Message: fmt.Sprintf("unrecognized media type for bid %s on imp %s", bid.ID, bid.ImpID),
	}
}

Notice the explicit population of typedBid.BidVideo when bidType == openrtb_ext.BidTypeVideo. In CTV ad podding and mobile video environments using hybrid mediation, Prebid Server relies on ExtBidPrebidVideo.Duration and PrimaryCategory to enforce competitive separation, pod duration pacing, and Google Ad Manager line-item key-value targeting (hb_pb_cat_dur). If an adapter omits BidVideo metadata when OpenRTB 2.6 bid.Dur and bid.Cat are present on the response, SSAI pod stitchers cannot deduplicate competing brands within a commercial break. Also note bid := seatBid.Bid[i] inside the loop: copying the loop variable into a local variable ensures &bid points to a distinct memory address across all Go compiler versions.

Defining static/bidder-params/{bidder}.json and Registering in openrtb_ext/bidders.go

Even if your Go code inside adapters/{bidder}/ compiles cleanly, Prebid Server Go will reject every incoming request for your bidder unless three registration artifacts are wired into the core repository: the JSON Schema validator file, the bidder enumerator in openrtb_ext, and the builder registry map in exchange.

First, create static/bidder-params/skyboost.json using JSON Schema Draft-04 syntax. PBS compiles all schema files into memory at startup and validates imp[].ext.prebid.bidder.{bidder} before MakeRequests() ever runs. By enforcing strict required properties, minLength rules, and additionalProperties: false inside the schema, you eliminate defensive string-emptiness boilerplate inside your Go adapter code:

{
  "$schema": "http://json-schema.org/draft-04/schema#",
  "title": "SkyBoost Adapter Params",
  "description": "Schema for the SkyBoost Prebid Server Go adapter",
  "type": "object",
  "properties": {
    "placementId": {
      "type": "string",
      "minLength": 1,
      "description": "Unique placement identifier assigned by SkyBoost"
    },
    "publisherId": {
      "type": "string",
      "minLength": 1,
      "description": "Publisher account identifier"
    },
    "bidFloor": {
      "type": "number",
      "minimum": 0,
      "description": "Optional CPM floor override in USD"
    }
  },
  "required": ["placementId", "publisherId"],
  "additionalProperties": false
}

Second, register the canonical BidderName constant in openrtb_ext/bidders.go and add it to the coreBidderNames slice so PBS recognizes the bidder code during request unmarshaling and alias resolution:

// Inside openrtb_ext/bidders.go
const (
	BidderSkyBoost BidderName = "skyboost"
)

var coreBidderNames = []BidderName{
	// ... existing bidders
	BidderSkyBoost,
}

Third, wire the Builder constructor into the newAdapterBuilders() map inside exchange/adapter_builders.go:

// Inside exchange/adapter_builders.go
func newAdapterBuilders() map[openrtb_ext.BidderName]adapters.Builder {
	return map[openrtb_ext.BidderName]adapters.Builder{
		// ... existing builders
		openrtb_ext.BidderSkyBoost: skyboost.Builder,
	}
}

Finally, create static/bidder-info/skyboost.yaml to declare maintainer email addresses, GVL vendor ID for GDPR TCF v2.2 compliance, supported media types (banner, video, native, audio) across app, site, and dooh channels, and user sync URL templates. If a media type is omitted from static/bidder-info/{bidder}.yaml, PBS will filter out impressions of that format before invoking your adapter.

Writing Unit Tests for Adapter PR Submission

The Prebid Server repository enforces strict automated quality gates for any custom adapter pull request or internal production deployment. Adapters must maintain over 90% line coverage, pass the JSON-driven adapterstest test harness, and run cleanly under the Go race detector (go test -race).

Rather than writing repetitive table-driven HTTP mocks by hand, PBS provides adapterstest.RunJSONBidderTest inside the github.com/prebid/prebid-server/v3/adapters/adapterstest package. This test runner scans two subdirectories inside adapters/{bidder}/{bidder}test/:

  • exemplary/: Happy-path end-to-end JSON test cases covering banner, video, native, and multi-impression flows. Each JSON file defines a mock inbound BidRequest, the expected outbound HttpRequests produced by MakeRequests(), a mock HttpResponse from the demand partner, and the expected BidResponses and BidType mappings returned by MakeBids().
  • supplemental/: Edge-case and failure-mode test fixtures verifying error classification for malformed JSON extensions, non-200 HTTP status codes, currency conversion failures, and missing MType fallbacks.

A complete unit test file (adapters/skyboost/skyboost_test.go) verifies both Builder initialization errors and the full suite of exemplary and supplemental JSON fixtures:

package skyboost

import (
	"testing"

	"github.com/prebid/prebid-server/v3/adapters/adapterstest"
	"github.com/prebid/prebid-server/v3/config"
	"github.com/prebid/prebid-server/v3/openrtb_ext"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestJsonSamples(t *testing.T) {
	bidder, buildErr := Builder(
		openrtb_ext.BidderSkyBoost,
		config.Adapter{Endpoint: "https://rtb.skyboosts.com/openrtb2/auction"},
		config.Server{ExternalUrl: "https://pbs.skyboosts.com", GvlID: 1, DataCenter: "us-east"},
	)
	require.NoError(t, buildErr)
	adapterstest.RunJSONBidderTest(t, "skyboosttest", bidder)
}

func TestBuilderEmptyEndpoint(t *testing.T) {
	_, buildErr := Builder(
		openrtb_ext.BidderSkyBoost,
		config.Adapter{Endpoint: ""},
		config.Server{},
	)
	assert.Error(t, buildErr)
}

When you execute go test -v -race -cover ./adapters/skyboost/..., RunJSONBidderTest also automatically runs assertAutoGenFormats, which serializes and compares every header, URI, body byte, typed bid, video duration attribute, and error type against your JSON fixtures. Before promoting a custom Go adapter into production clusters, always benchmark MakeRequests() and MakeBids() under concurrent goroutines using testing.B (b.RunParallel) with -benchmem. Eliminating unnecessary heap allocations inside the per-impression loop reduces garbage collection tail latency at p99, ensuring your bidder responds well inside the exchange's TMax cutoff and captures maximum clearing yield across every impression.

Next step

Put it to work.

Every pattern in this article runs on our unified SSP and monetization platform: GAM and MCM management, Prebid client and server-side, and CTV SSAI.

Net-30 / Net-60 terms · 100% brand-safe demand