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

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:
- 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. - Privacy, Consent, and Activity Enforcement: The privacy enforcement engine evaluates TCF v2.2 strings, Global Privacy Platform (
regs.gppandregs.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 scrubsdevice.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. - 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 instatic/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 toext.warningsrather than aborting the entire auction. - 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 fromrequest.imp[i].ext.prebid.bidder.{bidder}directly intorequest.imp[i].ext.bidder. This normalization guarantees that an adapter never needs to inspect other bidders' configurations or navigate the nestedext.prebidhierarchy directly. - 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'sMakeRequests()method synchronously within that bidder's goroutine to obtain a slice of*adapters.RequestDatastructs. PBS then dispatches those HTTP calls concurrently using its shared, connection-pooledhttp.Clientgoverned by acontext.Contextderived fromrequest.TMax. Crucially, adapters never executehttp.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.

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:
- Native Multi-Impression Endpoints: The downstream DSP or SSP ingests a single OpenRTB 2.6
BidRequestcontaining multipleImpobjects and returns a consolidatedBidResponse. This topology minimizes outbound HTTP socket exhaustion and TLS handshake overhead. - Single-Impression Split Endpoints: Legacy or specialized endpoints require one HTTP POST per
Impobject (or one HTTP POST per publisher account ID). In this model, an auction with 6 ad slots generates 6 elements in the[]*adapters.RequestDataslice returned byMakeRequests().
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. Returnnil, nilimmediately without allocating aBidderResponseor logging an error.http.StatusBadRequest(400): Indicates a malformed client payload. Return an&errortypes.BadInputerror 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.BadServerResponseto 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 inboundBidRequest, the expected outboundHttpRequestsproduced byMakeRequests(), a mockHttpResponsefrom the demand partner, and the expectedBidResponsesandBidTypemappings returned byMakeBids().supplemental/: Edge-case and failure-mode test fixtures verifying error classification for malformed JSON extensions, non-200 HTTP status codes, currency conversion failures, and missingMTypefallbacks.
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.