Repository: prebid/prebid-server
Pull request #2840 (2023-06-13 11:45:11)

Enable GPP support for PubMatic adapter
enabling `gpp-support` flag in bidderinfo.yaml under `openrtb`




Patch:
diff --git a/.gitignore b/.gitignore
index 514e217b..4e298f2a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,3 +51,8 @@ analytics/filesystem/testFiles/
 # autogenerated mac file
 
 .DS_Store
+
+# Autogenerated Vim swap files
+*~
+*.swp
+*.swo
diff --git a/adapters/appnexus/appnexus.go b/adapters/appnexus/appnexus.go
index fed3a66a..93a0bf95 100644
--- a/adapters/appnexus/appnexus.go
+++ b/adapters/appnexus/appnexus.go
@@ -64,8 +64,7 @@ func (a *adapter) MakeRequests(request *openrtb2.BidRequest, reqInfo *adapters.E
 
 	var (
 		shouldGenerateAdPodId *bool
-		uniqueMemberIds       = make([]string, 0, len(request.Imp))
-		memberIds             = make(map[string]struct{}, len(request.Imp))
+		uniqueMemberID        string
 		errs                  = make([]error, 0, len(request.Imp))
 	)
 
@@ -84,9 +83,14 @@ func (a *adapter) MakeRequests(request *openrtb2.BidRequest, reqInfo *adapters.E
 
 		memberId := appnexusExt.Member
 		if memberId != "" {
-			if _, ok := memberIds[memberId]; !ok {
-				memberIds[memberId] = struct{}{}
-				uniqueMemberIds = append(uniqueMemberIds, memberId)
+			// The Appnexus API requires a Member ID in the URL. This means the request may fail if
+			// different impressions have different member IDs.
+			// Check for this condition, and log an error if it's a problem.
+			if uniqueMemberID == "" {
+				uniqueMemberID = memberId
+			} else if uniqueMemberID != memberId {
+				errs = append(errs, fmt.Errorf("all request.imp[i].ext.prebid.bidder.appnexus.member params must match. Request contained member IDs %s and %s", uniqueMemberID, memberId))
+				return nil, errs
 			}
 		}
 
@@ -108,15 +112,9 @@ func (a *adapter) MakeRequests(request *openrtb2.BidRequest, reqInfo *adapters.E
 	}
 
 	requestURI := a.uri
-	// The Appnexus API requires a Member ID in the URL. This means the request may fail if
-	// different impressions have different member IDs.
-	// Check for this condition, and log an error if it's a problem.
-	if len(uniqueMemberIds) > 0 {
-		requestURI = appendMemberId(requestURI, uniqueMemberIds[0])
-		if len(uniqueMemberIds) > 1 {
-			errs = append(errs, fmt.Errorf("All request.imp[i].ext.prebid.bidder.appnexus.member params must match. Request contained: %v", uniqueMemberIds))
-			return nil, errs
-		}
+
+	if uniqueMemberID != "" {
+		requestURI = appendMemberId(requestURI, uniqueMemberID)
 	}
 
 	// Add Appnexus request level extension
@@ -127,24 +125,10 @@ func (a *adapter) MakeRequests(request *openrtb2.BidRequest, reqInfo *adapters.E
 		isVIDEO = 1
 	}
 
-	var reqExt appnexusReqExt
-	if len(request.Ext) > 0 {
-		if err := json.Unmarshal(request.Ext, &reqExt); err != nil {
-			errs = append(errs, err)
-			return nil, errs
-		}
-	}
-	if reqExt.Appnexus == nil {
-		reqExt.Appnexus = &appnexusReqExtAppnexus{}
-	}
-	includeBrandCategory := reqExt.Prebid.Targeting != nil && reqExt.Prebid.Targeting.IncludeBrandCategory != nil
-	if includeBrandCategory {
-		reqExt.Appnexus.BrandCategoryUniqueness = &includeBrandCategory
-		reqExt.Appnexus.IncludeBrandCategory = &includeBrandCategory
+	reqExt, err := a.getRequestExt(request.Ext, isAMP, isVIDEO)
+	if err != nil {
+		return nil, append(errs, err)
 	}
-	reqExt.Appnexus.IsAMP = isAMP
-	reqExt.Appnexus.HeaderBiddingSource = a.hbSource + isVIDEO
-
 	// For long form requests if adpodId feature enabled, adpod_id must be sent downstream.
 	// Adpod id is a unique identifier for pod
 	// All impressions in the same pod must have the same pod id in request extension
@@ -180,7 +164,7 @@ func (a *adapter) MakeBids(internalRequest *openrtb2.BidRequest, externalRequest
 		for i := range sb.Bid {
 			bid := sb.Bid[i]
 
-			var bidExt appnexusBidExt
+			var bidExt bidExt
 			if err := json.Unmarshal(bid.Ext, &bidExt); err != nil {
 				errs = append(errs, err)
 				continue
@@ -216,6 +200,27 @@ func (a *adapter) MakeBids(internalRequest *openrtb2.BidRequest, externalRequest
 	return bidderResponse, errs
 }
 
+func (a *adapter) getRequestExt(ext json.RawMessage, isAMP int, isVIDEO int) (bidReqExt, error) {
+	var reqExt bidReqExt
+	if len(ext) > 0 {
+		if err := json.Unmarshal(ext, &reqExt); err != nil {
+			return bidReqExt{}, err
+		}
+	}
+	if reqExt.Appnexus == nil {
+		reqExt.Appnexus = &bidReqExtAppnexus{}
+	}
+	includeBrandCategory := reqExt.Prebid.Targeting != nil && reqExt.Prebid.Targeting.IncludeBrandCategory != nil
+	if includeBrandCategory {
+		reqExt.Appnexus.BrandCategoryUniqueness = &includeBrandCategory
+		reqExt.Appnexus.IncludeBrandCategory = &includeBrandCategory
+	}
+	reqExt.Appnexus.IsAMP = isAMP
+	reqExt.Appnexus.HeaderBiddingSource = a.hbSource + isVIDEO
+
+	return reqExt, nil
+}
+
 func validateAndBuildAppNexusExt(imp *openrtb2.Imp) (openrtb_ext.ExtImpAppnexus, error) {
 	var bidderExt adapters.ExtImpBidder
 	if err := json.Unmarshal(imp.Ext, &bidderExt); err != nil {
@@ -261,7 +266,7 @@ func groupByPods(imps []openrtb2.Imp) map[string]([]openrtb2.Imp) {
 	return podImps
 }
 
-func splitRequests(imps []openrtb2.Imp, request *openrtb2.BidRequest, requestExtension appnexusReqExt, uri string) ([]*adapters.RequestData, []error) {
+func splitRequests(imps []openrtb2.Imp, request *openrtb2.BidRequest, requestExtension bidReqExt, uri string) ([]*adapters.RequestData, []error) {
 	var errs []error
 	// Initial capacity for future array of requests, memory optimization.
 	// Let's say there are 35 impressions and limit impressions per request equals to 10.
@@ -346,7 +351,7 @@ func buildRequestImp(imp *openrtb2.Imp, appnexusExt *openrtb_ext.ExtImpAppnexus,
 		imp.DisplayManagerVer = displayManagerVer
 	}
 
-	impExt := appnexusImpExt{Appnexus: appnexusImpExtAppnexus{
+	impExt := impExt{Appnexus: impExtAppnexus{
 		PlacementID:       int(appnexusExt.PlacementId),
 		TrafficSourceCode: appnexusExt.TrafficSourceCode,
 		Keywords:          appnexusExt.Keywords.String(),
@@ -363,7 +368,7 @@ func buildRequestImp(imp *openrtb2.Imp, appnexusExt *openrtb_ext.ExtImpAppnexus,
 }
 
 // getMediaTypeForBid determines which type of bid.
-func getMediaTypeForBid(bid *appnexusBidExt) (openrtb_ext.BidType, error) {
+func getMediaTypeForBid(bid *bidExt) (openrtb_ext.BidType, error) {
 	switch bid.Appnexus.BidType {
 	case 0:
 		return openrtb_ext.BidTypeBanner, nil
@@ -377,7 +382,7 @@ func getMediaTypeForBid(bid *appnexusBidExt) (openrtb_ext.BidType, error) {
 }
 
 // getIabCategoryForBid maps an appnexus brand id to an IAB category.
-func (a *adapter) findIabCategoryForBid(bid *appnexusBidExt) (string, bool) {
+func (a *adapter) findIabCategoryForBid(bid *bidExt) (string, bool) {
 	brandIDString := strconv.Itoa(bid.Appnexus.BrandCategory)
 	iabCategory, ok := iabCategoryMap[brandIDString]
 	return iabCategory, ok
@@ -408,7 +413,7 @@ func buildDisplayManageVer(req *openrtb2.BidRequest) string {
 	return fmt.Sprintf("%s-%s", source, version)
 }
 
-func (a *adapter) buildAdPodRequests(imps []openrtb2.Imp, request *openrtb2.BidRequest, requestExtension appnexusReqExt, uri string) ([]*adapters.RequestData, []error) {
+func (a *adapter) buildAdPodRequests(imps []openrtb2.Imp, request *openrtb2.BidRequest, requestExtension bidReqExt, uri string) ([]*adapters.RequestData, []error) {
 	var errs []error
 	podImps := groupByPods(imps)
 	requests := make([]*adapters.RequestData, 0, len(podImps))
diff --git a/adapters/appnexus/models.go b/adapters/appnexus/models.go
index afe6b748..826451b4 100644
--- a/adapters/appnexus/models.go
+++ b/adapters/appnexus/models.go
@@ -6,7 +6,7 @@ import (
 	"github.com/prebid/prebid-server/openrtb_ext"
 )
 
-type appnexusImpExtAppnexus struct {
+type impExtAppnexus struct {
 	PlacementID       int             `json:"placement_id,omitempty"`
 	Keywords          string          `json:"keywords,omitempty"`
 	TrafficSourceCode string          `json:"traffic_source_code,omitempty"`
@@ -16,31 +16,31 @@ type appnexusImpExtAppnexus struct {
 	ExternalImpId     string          `json:"external_imp_id,omitempty"`
 }
 
-type appnexusImpExt struct {
-	Appnexus appnexusImpExtAppnexus `json:"appnexus"`
+type impExt struct {
+	Appnexus impExtAppnexus `json:"appnexus"`
 }
 
-type appnexusBidExtVideo struct {
+type bidExtVideo struct {
 	Duration int `json:"duration"`
 }
 
-type appnexusBidExtCreative struct {
-	Video appnexusBidExtVideo `json:"video"`
+type bidExtCreative struct {
+	Video bidExtVideo `json:"video"`
 }
 
-type appnexusBidExtAppnexus struct {
-	BidType       int                    `json:"bid_ad_type"`
-	BrandId       int                    `json:"brand_id"`
-	BrandCategory int                    `json:"brand_category_id"`
-	CreativeInfo  appnexusBidExtCreative `json:"creative_info"`
-	DealPriority  int                    `json:"deal_priority"`
+type bidExtAppnexus struct {
+	BidType       int            `json:"bid_ad_type"`
+	BrandId       int            `json:"brand_id"`
+	BrandCategory int            `json:"brand_category_id"`
+	CreativeInfo  bidExtCreative `json:"creative_info"`
+	DealPriority  int            `json:"deal_priority"`
 }
 
-type appnexusBidExt struct {
-	Appnexus appnexusBidExtAppnexus `json:"appnexus"`
+type bidExt struct {
+	Appnexus bidExtAppnexus `json:"appnexus"`
 }
 
-type appnexusReqExtAppnexus struct {
+type bidReqExtAppnexus struct {
 	IncludeBrandCategory    *bool  `json:"include_brand_category,omitempty"`
 	BrandCategoryUniqueness *bool  `json:"brand_category_uniqueness,omitempty"`
 	IsAMP                   int    `json:"is_amp,omitempty"`
@@ -49,7 +49,7 @@ type appnexusReqExtAppnexus struct {
 }
 
 // Full request extension including appnexus extension object
-type appnexusReqExt struct {
+type bidReqExt struct {
 	openrtb_ext.ExtRequest
-	Appnexus *appnexusReqExtAppnexus `json:"appnexus,omitempty"`
+	Appnexus *bidReqExtAppnexus `json:"appnexus,omitempty"`
 }
diff --git a/config/account.go b/config/account.go
index 393194bc..8beff9b6 100644
--- a/config/account.go
+++ b/config/account.go
@@ -165,7 +165,8 @@ func (a *AccountGDPR) PurposeEnforced(purpose consentconstants.Purpose) (value,
 // PurposeEnforcementAlgo checks the purpose enforcement algo for a given purpose by first
 // looking at the account settings, and if not set there, defaulting to the host configuration.
 func (a *AccountGDPR) PurposeEnforcementAlgo(purpose consentconstants.Purpose) (value TCF2EnforcementAlgo, exists bool) {
-	c, exists := a.PurposeConfigs[purpose]
+	var c *AccountGDPRPurpose
+	c, exists = a.PurposeConfigs[purpose]
 
 	if exists && (c.EnforceAlgoID == TCF2BasicEnforcement || c.EnforceAlgoID == TCF2FullEnforcement) {
 		return c.EnforceAlgoID, true
diff --git a/config/config.go b/config/config.go
index eabf62ca..ee18c746 100644
--- a/config/config.go
+++ b/config/config.go
@@ -352,7 +352,7 @@ func (t *TCF2) IsEnabled() bool {
 
 // PurposeEnforced checks if full enforcement is turned on for a given purpose. With full enforcement enabled, the
 // GDPR full enforcement algorithm will execute for that purpose determining legal basis; otherwise it's skipped.
-func (t *TCF2) PurposeEnforced(purpose consentconstants.Purpose) (value bool) {
+func (t *TCF2) PurposeEnforced(purpose consentconstants.Purpose) (enforce bool) {
 	if t.PurposeConfigs[purpose] == nil {
 		return false
 	}
@@ -360,7 +360,7 @@ func (t *TCF2) PurposeEnforced(purpose consentconstants.Purpose) (value bool) {
 }
 
 // PurposeEnforcementAlgo returns the default enforcement algorithm for a given purpose
-func (t *TCF2) PurposeEnforcementAlgo(purpose consentconstants.Purpose) (value TCF2EnforcementAlgo) {
+func (t *TCF2) PurposeEnforcementAlgo(purpose consentconstants.Purpose) (enforcement TCF2EnforcementAlgo) {
 	if c, exists := t.PurposeConfigs[purpose]; exists {
 		return c.EnforceAlgoID
 	}
@@ -369,7 +369,7 @@ func (t *TCF2) PurposeEnforcementAlgo(purpose consentconstants.Purpose) (value T
 
 // PurposeEnforcingVendors checks if enforcing vendors is turned on for a given purpose. With enforcing vendors
 // enabled, the GDPR full enforcement algorithm considers the GVL when determining legal basis; otherwise it's skipped.
-func (t *TCF2) PurposeEnforcingVendors(purpose consentconstants.Purpose) (value bool) {
+func (t *TCF2) PurposeEnforcingVendors(purpose consentconstants.Purpose) (enforce bool) {
 	if t.PurposeConfigs[purpose] == nil {
 		return false
 	}
@@ -378,7 +378,7 @@ func (t *TCF2) PurposeEnforcingVendors(purpose consentconstants.Purpose) (value
 
 // PurposeVendorExceptions returns the vendor exception map for a given purpose if it exists, otherwise it returns
 // an empty map of vendor exceptions
-func (t *TCF2) PurposeVendorExceptions(purpose consentconstants.Purpose) (value map[openrtb_ext.BidderName]struct{}) {
+func (t *TCF2) PurposeVendorExceptions(purpose consentconstants.Purpose) (vendorExceptions map[openrtb_ext.BidderName]struct{}) {
 	c, exists := t.PurposeConfigs[purpose]
 
 	if exists && c.VendorExceptionMap != nil {
@@ -389,13 +389,13 @@ func (t *TCF2) PurposeVendorExceptions(purpose consentconstants.Purpose) (value
 
 // FeatureOneEnforced checks if special feature one is enforced. If it is enforced, PBS will determine whether geo
 // information may be passed through in the bid request.
-func (t *TCF2) FeatureOneEnforced() (value bool) {
+func (t *TCF2) FeatureOneEnforced() bool {
 	return t.SpecialFeature1.Enforce
 }
 
 // FeatureOneVendorException checks if the specified bidder is considered a vendor exception for special feature one.
 // If a bidder is a vendor exception, PBS will bypass the pass geo calculation passing the geo information in the bid request.
-func (t *TCF2) FeatureOneVendorException(bidder openrtb_ext.BidderName) (value bool) {
+func (t *TCF2) FeatureOneVendorException(bidder openrtb_ext.BidderName) bool {
 	if _, ok := t.SpecialFeature1.VendorExceptionMap[bidder]; ok {
 		return true
 	}
@@ -403,12 +403,12 @@ func (t *TCF2) FeatureOneVendorException(bidder openrtb_ext.BidderName) (value b
 }
 
 // PurposeOneTreatmentEnabled checks if purpose one treatment is enabled.
-func (t *TCF2) PurposeOneTreatmentEnabled() (value bool) {
+func (t *TCF2) PurposeOneTreatmentEnabled() bool {
 	return t.PurposeOneTreatment.Enabled
 }
 
 // PurposeOneTreatmentAccessAllowed checks if purpose one treatment access is allowed.
-func (t *TCF2) PurposeOneTreatmentAccessAllowed() (value bool) {
+func (t *TCF2) PurposeOneTreatmentAccessAllowed() bool {
 	return t.PurposeOneTreatment.AccessAllowed
 }
 
diff --git a/endpoints/events/event.go b/endpoints/events/event.go
index ba77f3ce..089d5606 100644
--- a/endpoints/events/event.go
+++ b/endpoints/events/event.go
@@ -217,7 +217,7 @@ func HandleAccountServiceErrors(errs []error) (status int, messages []string) {
 		}
 	}
 
-	return status, messages
+	return
 }
 
 func optionalParameters(request *analytics.EventRequest) string {
diff --git a/endpoints/openrtb2/amp_auction.go b/endpoints/openrtb2/amp_auction.go
index 0947c6f4..73f4842a 100644
--- a/endpoints/openrtb2/amp_auction.go
+++ b/endpoints/openrtb2/amp_auction.go
@@ -804,7 +804,7 @@ func setEffectiveAmpPubID(req *openrtb2.BidRequest, account string) {
 	}
 }
 
-func setTrace(req *openrtb2.BidRequest, value string) (err error) {
+func setTrace(req *openrtb2.BidRequest, value string) error {
 	if value == "" {
 		return nil
 	}
diff --git a/exchange/exchange.go b/exchange/exchange.go
index 4251c3e2..24ca193f 100644
--- a/exchange/exchange.go
+++ b/exchange/exchange.go
@@ -1006,24 +1006,11 @@ func applyCategoryMapping(ctx context.Context, targeting openrtb_ext.ExtRequestT
 
 			priceBucket = GetPriceBucket(*bid.Bid, *targData)
 
-			newDur := duration
-			if len(targeting.DurationRangeSec) > 0 {
-				durationRange := make([]int, len(targeting.DurationRangeSec))
-				copy(durationRange, targeting.DurationRangeSec)
-				sort.Ints(durationRange)
-
-				//if the bid is above the range of the listed durations (and outside the buffer), reject the bid
-				if duration > durationRange[len(durationRange)-1] {
-					bidsToRemove = append(bidsToRemove, bidInd)
-					rejections = updateRejections(rejections, bidID, "Bid duration exceeds maximum allowed")
-					continue
-				}
-				for _, dur := range durationRange {
-					if duration <= dur {
-						newDur = dur
-						break
-					}
-				}
+			newDur, err := findDurationRange(duration, targeting.DurationRangeSec)
+			if err != nil {
+				bidsToRemove = append(bidsToRemove, bidInd)
+				rejections = updateRejections(rejections, bidID, err.Error())
+				continue
 			}
 
 			var categoryDuration string
@@ -1113,6 +1100,33 @@ func applyCategoryMapping(ctx context.Context, targeting openrtb_ext.ExtRequestT
 	return res, seatBids, rejections, nil
 }
 
+// findDurationRange returns the element in the array 'durationRanges' that is both greater than 'dur' and closest
+// in value to 'dur' unless a value equal to 'dur' is found. Returns an error if all elements in 'durationRanges'
+// are less than 'dur'.
+func findDurationRange(dur int, durationRanges []int) (int, error) {
+	newDur := dur
+	madeSelection := false
+	var err error
+
+	for i := range durationRanges {
+		if dur > durationRanges[i] {
+			continue
+		}
+		if dur == durationRanges[i] {
+			return durationRanges[i], nil
+		}
+		// dur < durationRanges[i]
+		if durationRanges[i] < newDur || !madeSelection {
+			newDur = durationRanges[i]
+			madeSelection = true
+		}
+	}
+	if !madeSelection && len(durationRanges) > 0 {
+		err = errors.New("bid duration exceeds maximum allowed")
+	}
+	return newDur, err
+}
+
 func removeBidById(seatBid *entities.PbsOrtbSeatBid, bidID string) {
 	//Find index of bid to remove
 	dupeBidIndex := -1
@@ -1191,6 +1205,9 @@ func (e *exchange) makeExtBidResponse(adapterBids map[openrtb_ext.BidderName]*en
 		}
 		if len(errList) > 0 {
 			bidResponseExt.Errors[openrtb_ext.PrebidExtKey] = errsToBidderErrors(errList)
+			if prebidWarn := errsToBidderWarnings(errList); len(prebidWarn) > 0 {
+				bidResponseExt.Warnings[openrtb_ext.PrebidExtKey] = prebidWarn
+			}
 		}
 		bidResponseExt.ResponseTimeMillis[bidderName] = responseExtra.ResponseTimeMillis
 		// Defering the filling of bidResponseExt.Usersync[bidderName] until later
diff --git a/firstpartydata/extmerger.go b/firstpartydata/extmerger.go
new file mode 100644
index 00000000..119fa8a4
--- /dev/null
+++ b/firstpartydata/extmerger.go
@@ -0,0 +1,60 @@
+package firstpartydata
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+
+	"github.com/prebid/prebid-server/util/sliceutil"
+	jsonpatch "gopkg.in/evanphx/json-patch.v4"
+)
+
+var (
+	ErrBadRequest = fmt.Errorf("invalid request ext")
+	ErrBadFPD     = fmt.Errorf("invalid first party data ext")
+)
+
+// extMerger tracks a JSON `ext` field within an OpenRTB request. The value of the
+// `ext` field is expected to be modified when calling unmarshal on the same object
+// and will later be updated when invoking Merge.
+type extMerger struct {
+	ext      *json.RawMessage // Pointer to the JSON `ext` field.
+	snapshot json.RawMessage  // Copy of the original state of the JSON `ext` field.
+}
+
+// Track saves a copy of the JSON `ext` field and stores a reference to the extension
+// object for comparison later in the Merge call.
+func (e *extMerger) Track(ext *json.RawMessage) {
+	e.ext = ext
+	e.snapshot = sliceutil.Clone(*ext)
+}
+
+// Merge applies a JSON merge of the stored extension snapshot on top of the current
+// JSON of the tracked extension object.
+func (e extMerger) Merge() error {
+	if e.ext == nil {
+		return nil
+	}
+
+	if len(e.snapshot) == 0 {
+		return nil
+	}
+
+	if len(*e.ext) == 0 {
+		*e.ext = e.snapshot
+		return nil
+	}
+
+	merged, err := jsonpatch.MergePatch(e.snapshot, *e.ext)
+	if err != nil {
+		if errors.Is(err, jsonpatch.ErrBadJSONDoc) {
+			return ErrBadRequest
+		} else if errors.Is(err, jsonpatch.ErrBadJSONPatch) {
+			return ErrBadFPD
+		}
+		return err
+	}
+
+	*e.ext = merged
+	return nil
+}
diff --git a/firstpartydata/first_party_data.go b/firstpartydata/first_party_data.go
index 6ca7c03a..0fde931d 100644
--- a/firstpartydata/first_party_data.go
+++ b/firstpartydata/first_party_data.go
@@ -9,6 +9,8 @@ import (
 
 	"github.com/prebid/prebid-server/errortypes"
 	"github.com/prebid/prebid-server/openrtb_ext"
+	"github.com/prebid/prebid-server/ortb"
+	"github.com/prebid/prebid-server/util/ptrutil"
 )
 
 const (
@@ -16,27 +18,10 @@ const (
 	appKey  = "app"
 	userKey = "user"
 	dataKey = "data"
-	extKey  = "ext"
 
 	userDataKey        = "userData"
 	appContentDataKey  = "appContentData"
 	siteContentDataKey = "siteContentData"
-
-	keywordsKey   = "keywords"
-	genderKey     = "gender"
-	yobKey        = "yob"
-	pageKey       = "page"
-	nameKey       = "name"
-	domainKey     = "domain"
-	catKey        = "cat"
-	sectionCatKey = "sectioncat"
-	pageCatKey    = "pagecat"
-	searchKey     = "search"
-	refKey        = "ref"
-	bundleKey     = "bundle"
-	storeUrlKey   = "storeurl"
-	verKey        = "ver"
-	contentKey    = "content"
 )
 
 type ResolvedFirstPartyData struct {
@@ -47,7 +32,6 @@ type ResolvedFirstPartyData struct {
 
 // ExtractGlobalFPD extracts request level FPD from the request and removes req.{site,app,user}.ext.data if exists
 func ExtractGlobalFPD(req *openrtb_ext.RequestWrapper) (map[string][]byte, error) {
-
 	fpdReqData := make(map[string][]byte, 3)
 
 	siteExt, err := req.GetSiteExt()
@@ -88,7 +72,7 @@ func ExtractGlobalFPD(req *openrtb_ext.RequestWrapper) (map[string][]byte, error
 		refreshExt = true
 	}
 	if refreshExt {
-		//need to keep site/app/user ext clean in case bidder is not in global fpd bidder list
+		// need to keep site/app/user ext clean in case bidder is not in global fpd bidder list
 		// rebuild/resync the request in the request wrapper.
 		if err := req.RebuildRequest(); err != nil {
 			return nil, err
@@ -100,7 +84,6 @@ func ExtractGlobalFPD(req *openrtb_ext.RequestWrapper) (map[string][]byte, error
 
 // ExtractOpenRtbGlobalFPD extracts and deletes user.data and {app/site}.content.data from request
 func ExtractOpenRtbGlobalFPD(bidRequest *openrtb2.BidRequest) map[string][]openrtb2.Data {
-
 	openRtbGlobalFPD := make(map[string][]openrtb2.Data, 3)
 	if bidRequest.User != nil && len(bidRequest.User.Data) > 0 {
 		openRtbGlobalFPD[userDataKey] = bidRequest.User.Data
@@ -118,7 +101,6 @@ func ExtractOpenRtbGlobalFPD(bidRequest *openrtb2.BidRequest) map[string][]openr
 	}
 
 	return openRtbGlobalFPD
-
 }
 
 // ResolveFPD consolidates First Party Data from different sources and returns valid FPD that will be applied to bidders later or returns errors
@@ -130,14 +112,14 @@ func ResolveFPD(bidRequest *openrtb2.BidRequest, fpdBidderConfigData map[openrtb
 	allBiddersTable := make(map[string]struct{})
 
 	if biddersWithGlobalFPD == nil {
-		//add all bidders in bidder configs to receive global data and bidder specific data
+		// add all bidders in bidder configs to receive global data and bidder specific data
 		for bidderName := range fpdBidderConfigData {
 			if _, present := allBiddersTable[string(bidderName)]; !present {
 				allBiddersTable[string(bidderName)] = struct{}{}
 			}
 		}
 	} else {
-		//only bidders in global bidder list will receive global data and bidder specific data
+		// only bidders in global bidder list will receive global data and bidder specific data
 		for _, bidderName := range biddersWithGlobalFPD {
 			if _, present := allBiddersTable[string(bidderName)]; !present {
 				allBiddersTable[string(bidderName)] = struct{}{}
@@ -146,7 +128,6 @@ func ResolveFPD(bidRequest *openrtb2.BidRequest, fpdBidderConfigData map[openrtb
 	}
 
 	for bidderName := range allBiddersTable {
-
 		fpdConfig := fpdBidderConfigData[openrtb_ext.BidderName(bidderName)]
 
 		resolvedFpdConfig := &ResolvedFirstPartyData{}
@@ -177,7 +158,7 @@ func ResolveFPD(bidRequest *openrtb2.BidRequest, fpdBidderConfigData map[openrtb
 }
 
 func resolveUser(fpdConfig *openrtb_ext.ORTB2, bidRequestUser *openrtb2.User, globalFPD map[string][]byte, openRtbGlobalFPD map[string][]openrtb2.Data, bidderName string) (*openrtb2.User, error) {
-	var fpdConfigUser map[string]json.RawMessage
+	var fpdConfigUser json.RawMessage
 
 	if fpdConfig != nil && fpdConfig.User != nil {
 		fpdConfigUser = fpdConfig.User
@@ -187,18 +168,22 @@ func resolveUser(fpdConfig *openrtb_ext.ORTB2, bidRequestUser *openrtb2.User, gl
 		return nil, nil
 	}
 
-	newUser := openrtb2.User{}
+	var newUser *openrtb2.User
 	if bidRequestUser != nil {
-		newUser = *bidRequestUser
+		newUser = ptrutil.Clone(bidRequestUser)
+	} else {
+		newUser = &openrtb2.User{}
 	}
 
-	var err error
-
 	//apply global fpd
 	if len(globalFPD[userKey]) > 0 {
 		extData := buildExtData(globalFPD[userKey])
 		if len(newUser.Ext) > 0 {
+			var err error
 			newUser.Ext, err = jsonpatch.MergePatch(newUser.Ext, extData)
+			if err != nil {
+				return nil, err
+			}
 		} else {
 			newUser.Ext = extData
 		}
@@ -207,122 +192,44 @@ func resolveUser(fpdConfig *openrtb_ext.ORTB2, bidRequestUser *openrtb2.User, gl
 		newUser.Data = openRtbGlobalFPD[userDataKey]
 	}
 	if fpdConfigUser != nil {
-		//apply bidder specific fpd if present
-		newUser, err = mergeUsers(&newUser, fpdConfigUser)
-	}
-
-	return &newUser, err
-}
-
-func unmarshalJSONToInt64(input json.RawMessage) (int64, error) {
-	var num json.Number
-	err := json.Unmarshal(input, &num)
-	if err != nil {
-		return -1, err
-	}
-	result, err := num.Int64()
-	return result, err
-}
-
-func unmarshalJSONToString(input json.RawMessage) (string, error) {
-	var result string
-	err := json.Unmarshal(input, &result)
-	return result, err
-}
-
-func unmarshalJSONToData(input json.RawMessage) ([]openrtb2.Data, error) {
-	var result []openrtb2.Data
-	err := json.Unmarshal(input, &result)
-	return result, err
-}
-
-func unmarshalJSONToStringArray(input json.RawMessage) ([]string, error) {
-	var result []string
-	err := json.Unmarshal(input, &result)
-	return result, err
-}
-
-func unmarshalJSONToContent(input json.RawMessage) (*openrtb2.Content, error) {
-	var result openrtb2.Content
-	err := json.Unmarshal(input, &result)
-	return &result, err
-}
-
-// resolveExtension inserts remaining {site/app/user} attributes back to {site/app/user}.ext.data
-func resolveExtension(fpdConfig map[string]json.RawMessage, originalExt json.RawMessage) ([]byte, error) {
-	resExt := originalExt
-	var err error
-
-	if resExt == nil && len(fpdConfig) > 0 {
-		fpdExt, err := json.Marshal(fpdConfig)
-		return buildExtData(fpdExt), err
-	}
-
-	fpdConfigExt, present := fpdConfig[extKey]
-	if present {
-		delete(fpdConfig, extKey)
-		resExt, err = jsonpatch.MergePatch(resExt, fpdConfigExt)
-		if err != nil {
+		if err := mergeUser(newUser, fpdConfigUser); err != nil {
 			return nil, err
 		}
 	}
 
-	if len(fpdConfig) > 0 {
-		fpdData, err := json.Marshal(fpdConfig)
-		if err != nil {
-			return nil, err
-		}
-		data := buildExtData(fpdData)
-		return jsonpatch.MergePatch(resExt, data)
-	}
-	return resExt, nil
+	return newUser, nil
 }
 
-func mergeUsers(original *openrtb2.User, fpdConfigUser map[string]json.RawMessage) (openrtb2.User, error) {
+func mergeUser(v *openrtb2.User, overrideJSON json.RawMessage) error {
+	*v = *ortb.CloneUser(v)
 
-	var err error
-	newUser := *original
-
-	if keywords, present := fpdConfigUser[keywordsKey]; present {
-		newUser.Keywords, err = unmarshalJSONToString(keywords)
-		if err != nil {
-			return newUser, err
-		}
-		delete(fpdConfigUser, keywordsKey)
-	}
-	if gender, present := fpdConfigUser[genderKey]; present {
-		newUser.Gender, err = unmarshalJSONToString(gender)
-		if err != nil {
-			return newUser, err
-		}
-		delete(fpdConfigUser, genderKey)
-	}
-	if yob, present := fpdConfigUser[yobKey]; present {
-		newUser.Yob, err = unmarshalJSONToInt64(yob)
-		if err != nil {
-			return newUser, err
-		}
-		delete(fpdConfigUser, yobKey)
+	// Track EXTs
+	// It's not necessary to track `ext` fields in array items because the array
+	// items will be replaced entirely with the override JSON, so no merge is required.
+	var ext, extGeo extMerger
+	ext.Track(&v.Ext)
+	if v.Geo != nil {
+		extGeo.Track(&v.Geo.Ext)
 	}
 
-	if userData, present := fpdConfigUser[dataKey]; present {
-		newUserData, err := unmarshalJSONToData(userData)
-		if err != nil {
-			return newUser, err
-		}
-		newUser.Data = append(newUser.Data, newUserData...)
-		delete(fpdConfigUser, dataKey)
+	// Merge
+	if err := json.Unmarshal(overrideJSON, &v); err != nil {
+		return err
 	}
 
-	if len(fpdConfigUser) > 0 {
-		newUser.Ext, err = resolveExtension(fpdConfigUser, original.Ext)
+	// Merge EXTs
+	if err := ext.Merge(); err != nil {
+		return err
+	}
+	if err := extGeo.Merge(); err != nil {
+		return err
 	}
 
-	return newUser, err
+	return nil
 }
 
 func resolveSite(fpdConfig *openrtb_ext.ORTB2, bidRequestSite *openrtb2.Site, globalFPD map[string][]byte, openRtbGlobalFPD map[string][]openrtb2.Data, bidderName string) (*openrtb2.Site, error) {
-	var fpdConfigSite map[string]json.RawMessage
+	var fpdConfigSite json.RawMessage
 
 	if fpdConfig != nil && fpdConfig.Site != nil {
 		fpdConfigSite = fpdConfig.Site
@@ -337,14 +244,22 @@ func resolveSite(fpdConfig *openrtb_ext.ORTB2, bidRequestSite *openrtb2.Site, gl
 		}
 	}
 
-	newSite := *bidRequestSite
-	var err error
+	var newSite *openrtb2.Site
+	if bidRequestSite != nil {
+		newSite = ptrutil.Clone(bidRequestSite)
+	} else {
+		newSite = &openrtb2.Site{}
+	}
 
 	//apply global fpd
 	if len(globalFPD[siteKey]) > 0 {
 		extData := buildExtData(globalFPD[siteKey])
 		if len(newSite.Ext) > 0 {
+			var err error
 			newSite.Ext, err = jsonpatch.MergePatch(newSite.Ext, extData)
+			if err != nil {
+				return nil, err
+			}
 		} else {
 			newSite.Ext = extData
 		}
@@ -359,113 +274,77 @@ func resolveSite(fpdConfig *openrtb_ext.ORTB2, bidRequestSite *openrtb2.Site, gl
 		}
 		newSite.Content.Data = openRtbGlobalFPD[siteContentDataKey]
 	}
-
-	newSite, err = mergeSites(&newSite, fpdConfigSite, bidderName)
-	return &newSite, err
-
+	if fpdConfigSite != nil {
+		if err := mergeSite(newSite, fpdConfigSite, bidderName); err != nil {
+			return nil, err
+		}
+	}
+	return newSite, nil
 }
-func mergeSites(originalSite *openrtb2.Site, fpdConfigSite map[string]json.RawMessage, bidderName string) (openrtb2.Site, error) {
 
-	var err error
-	newSite := *originalSite
+func mergeSite(v *openrtb2.Site, overrideJSON json.RawMessage, bidderName string) error {
+	*v = *ortb.CloneSite(v)
 
-	if fpdConfigSite == nil {
-		return newSite, err
+	// Track EXTs
+	// It's not necessary to track `ext` fields in array items because the array
+	// items will be replaced entirely with the override JSON, so no merge is required.
+	var ext, extPublisher, extContent, extContentProducer, extContentNetwork, extContentChannel extMerger
+	ext.Track(&v.Ext)
+	if v.Publisher != nil {
+		extPublisher.Track(&v.Publisher.Ext)
 	}
-
-	//apply bidder specific fpd if present
-	if page, present := fpdConfigSite[pageKey]; present {
-		sitePage, err := unmarshalJSONToString(page)
-		if err != nil {
-			return newSite, err
-		}
-		//apply bidder specific fpd if present
-		//result site should have ID or Page, fpd becomes incorrect if it overwrites page to empty one and ID is empty in original site
-		if sitePage == "" && newSite.Page != "" && newSite.ID == "" {
-			return newSite, &errortypes.BadInput{
-				Message: fmt.Sprintf("incorrect First Party Data for bidder %s: Site object cannot set empty page if req.site.id is empty", bidderName),
-			}
-
-		}
-		newSite.Page = sitePage
-		delete(fpdConfigSite, pageKey)
+	if v.Content != nil {
+		extContent.Track(&v.Content.Ext)
 	}
-	if name, present := fpdConfigSite[nameKey]; present {
-		newSite.Name, err = unmarshalJSONToString(name)
-		if err != nil {
-			return newSite, err
-		}
-		delete(fpdConfigSite, nameKey)
+	if v.Content != nil && v.Content.Producer != nil {
+		extContentProducer.Track(&v.Content.Producer.Ext)
 	}
-	if domain, present := fpdConfigSite[domainKey]; present {
-		newSite.Domain, err = unmarshalJSONToString(domain)
-		if err != nil {
-			return newSite, err
-		}
-		delete(fpdConfigSite, domainKey)
+	if v.Content != nil && v.Content.Network != nil {
+		extContentNetwork.Track(&v.Content.Network.Ext)
 	}
-	if cat, present := fpdConfigSite[catKey]; present {
-		newSite.Cat, err = unmarshalJSONToStringArray(cat)
-		if err != nil {
-			return newSite, err
-		}
-		delete(fpdConfigSite, catKey)
+	if v.Content != nil && v.Content.Channel != nil {
+		extContentChannel.Track(&v.Content.Channel.Ext)
 	}
-	if sectionCat, present := fpdConfigSite[sectionCatKey]; present {
-		newSite.SectionCat, err = unmarshalJSONToStringArray(sectionCat)
-		if err != nil {
-			return newSite, err
-		}
-		delete(fpdConfigSite, sectionCatKey)
+
+	// Merge
+	if err := json.Unmarshal(overrideJSON, &v); err != nil {
+		return err
 	}
-	if pageCat, present := fpdConfigSite[pageCatKey]; present {
-		newSite.PageCat, err = unmarshalJSONToStringArray(pageCat)
-		if err != nil {
-			return newSite, err
-		}
-		delete(fpdConfigSite, pageCatKey)
+
+	// Merge EXTs
+	if err := ext.Merge(); err != nil {
+		return err
 	}
-	if search, present := fpdConfigSite[searchKey]; present {
-		newSite.Search, err = unmarshalJSONToString(search)
-		if err != nil {
-			return newSite, err
-		}
-		delete(fpdConfigSite, searchKey)
+	if err := extPublisher.Merge(); err != nil {
+		return err
 	}
-	if keywords, present := fpdConfigSite[keywordsKey]; present {
-		newSite.Keywords, err = unmarshalJSONToString(keywords)
-		if err != nil {
-			return newSite, err
-		}
-		delete(fpdConfigSite, keywordsKey)
+	if err := extContent.Merge(); err != nil {
+		return err
 	}
-	if ref, present := fpdConfigSite[refKey]; present {
-		newSite.Ref, err = unmarshalJSONToString(ref)
-		if err != nil {
-			return newSite, err
-		}
-		delete(fpdConfigSite, refKey)
+	if err := extContentProducer.Merge(); err != nil {
+		return err
 	}
-	if siteContent, present := fpdConfigSite[contentKey]; present {
-		newSite.Content, err = mergeContents(originalSite.Content, siteContent)
-		if err != nil {
-			return newSite, err
-		}
-		delete(fpdConfigSite, contentKey)
+	if err := extContentNetwork.Merge(); err != nil {
+		return err
+	}
+	if err := extContentChannel.Merge(); err != nil {
+		return err
 	}
 
-	if len(fpdConfigSite) > 0 {
-		newSite.Ext, err = resolveExtension(fpdConfigSite, originalSite.Ext)
+	// Re-Validate Site
+	if v.ID == "" && v.Page == "" {
+		return &errortypes.BadInput{
+			Message: fmt.Sprintf("incorrect First Party Data for bidder %s: Site object cannot set empty page if req.site.id is empty", bidderName),
+		}
 	}
 
-	return newSite, err
+	return nil
 }
 
 func resolveApp(fpdConfig *openrtb_ext.ORTB2, bidRequestApp *openrtb2.App, globalFPD map[string][]byte, openRtbGlobalFPD map[string][]openrtb2.Data, bidderName string) (*openrtb2.App, error) {
+	var fpdConfigApp json.RawMessage
 
-	var fpdConfigApp map[string]json.RawMessage
-
-	if fpdConfig != nil && fpdConfig.App != nil {
+	if fpdConfig != nil {
 		fpdConfigApp = fpdConfig.App
 	}
 
@@ -479,14 +358,22 @@ func resolveApp(fpdConfig *openrtb_ext.ORTB2, bidRequestApp *openrtb2.App, globa
 		}
 	}
 
-	newApp := *bidRequestApp
-	var err error
+	var newApp *openrtb2.App
+	if bidRequestApp != nil {
+		newApp = ptrutil.Clone(bidRequestApp)
+	} else {
+		newApp = &openrtb2.App{}
+	}
 
 	//apply global fpd if exists
 	if len(globalFPD[appKey]) > 0 {
 		extData := buildExtData(globalFPD[appKey])
 		if len(newApp.Ext) > 0 {
+			var err error
 			newApp.Ext, err = jsonpatch.MergePatch(newApp.Ext, extData)
+			if err != nil {
+				return nil, err
+			}
 		} else {
 			newApp.Ext = extData
 		}
@@ -503,100 +390,69 @@ func resolveApp(fpdConfig *openrtb_ext.ORTB2, bidRequestApp *openrtb2.App, globa
 		newApp.Content.Data = openRtbGlobalFPD[appContentDataKey]
 	}
 
-	newApp, err = mergeApps(&newApp, fpdConfigApp)
+	if fpdConfigApp != nil {
+		if err := mergeApp(newApp, fpdConfigApp); err != nil {
+			return nil, err
+		}
+	}
 
-	return &newApp, err
+	return newApp, nil
 }
 
-func mergeApps(originalApp *openrtb2.App, fpdConfigApp map[string]json.RawMessage) (openrtb2.App, error) {
-
-	var err error
-	newApp := *originalApp
+func mergeApp(v *openrtb2.App, overrideJSON json.RawMessage) error {
+	*v = *ortb.CloneApp(v)
 
-	if fpdConfigApp == nil {
-		return newApp, err
+	// Track EXTs
+	// It's not necessary to track `ext` fields in array items because the array
+	// items will be replaced entirely with the override JSON, so no merge is required.
+	var ext, extPublisher, extContent, extContentProducer, extContentNetwork, extContentChannel extMerger
+	ext.Track(&v.Ext)
+	if v.Publisher != nil {
+		extPublisher.Track(&v.Publisher.Ext)
 	}
-	//apply bidder specific fpd if present
-	if name, present := fpdConfigApp[nameKey]; present {
-		newApp.Name, err = unmarshalJSONToString(name)
-		if err != nil {
-			return newApp, err
-		}
-		delete(fpdConfigApp, nameKey)
+	if v.Content != nil {
+		extContent.Track(&v.Content.Ext)
 	}
-	if bundle, present := fpdConfigApp[bundleKey]; present {
-		newApp.Bundle, err = unmarshalJSONToString(bundle)
-		if err != nil {
-			return newApp, err
-		}
-		delete(fpdConfigApp, bundleKey)
+	if v.Content != nil && v.Content.Producer != nil {
+		extContentProducer.Track(&v.Content.Producer.Ext)
 	}
-	if domain, present := fpdConfigApp[domainKey]; present {
-		newApp.Domain, err = unmarshalJSONToString(domain)
-		if err != nil {
-			return newApp, err
-		}
-		delete(fpdConfigApp, domainKey)
+	if v.Content != nil && v.Content.Network != nil {
+		extContentNetwork.Track(&v.Content.Network.Ext)
 	}
-	if storeUrl, present := fpdConfigApp[storeUrlKey]; present {
-		newApp.StoreURL, err = unmarshalJSONToString(storeUrl)
-		if err != nil {
-			return newApp, err
-		}
-		delete(fpdConfigApp, storeUrlKey)
+	if v.Content != nil && v.Content.Channel != nil {
+		extContentChannel.Track(&v.Content.Channel.Ext)
 	}
-	if cat, present := fpdConfigApp[catKey]; present {
-		newApp.Cat, err = unmarshalJSONToStringArray(cat)
-		if err != nil {
-			return newApp, err
-		}
-		delete(fpdConfigApp, catKey)
+
+	// Merge
+	if err := json.Unmarshal(overrideJSON, &v); err != nil {
+		return err
 	}
-	if sectionCat, present := fpdConfigApp[sectionCatKey]; present {
-		newApp.SectionCat, err = unmarshalJSONToStringArray(sectionCat)
-		if err != nil {
-			return newApp, err
-		}
-		delete(fpdConfigApp, sectionCatKey)
+
+	// Merge EXTs
+	if err := ext.Merge(); err != nil {
+		return err
 	}
-	if pageCat, present := fpdConfigApp[pageCatKey]; present {
-		newApp.PageCat, err = unmarshalJSONToStringArray(pageCat)
-		if err != nil {
-			return newApp, err
-		}
-		delete(fpdConfigApp, pageCatKey)
+	if err := extPublisher.Merge(); err != nil {
+		return err
 	}
-	if version, present := fpdConfigApp[verKey]; present {
-		newApp.Ver, err = unmarshalJSONToString(version)
-		if err != nil {
-			return newApp, err
-		}
-		delete(fpdConfigApp, verKey)
+	if err := extContent.Merge(); err != nil {
+		return err
 	}
-	if keywords, present := fpdConfigApp[keywordsKey]; present {
-		newApp.Keywords, err = unmarshalJSONToString(keywords)
-		if err != nil {
-			return newApp, err
-		}
-		delete(fpdConfigApp, keywordsKey)
+	if err := extContentProducer.Merge(); err != nil {
+		return err
 	}
-	if appContent, present := fpdConfigApp[contentKey]; present {
-		newApp.Content, err = mergeContents(originalApp.Content, appContent)
-		if err != nil {
-			return newApp, err
-		}
-		delete(fpdConfigApp, contentKey)
+	if err := extContentNetwork.Merge(); err != nil {
+		return err
 	}
-
-	if len(fpdConfigApp) > 0 {
-		newApp.Ext, err = resolveExtension(fpdConfigApp, originalApp.Ext)
+	if err := extContentChannel.Merge(); err != nil {
+		return err
 	}
 
-	return newApp, err
+	return nil
 }
 
 func buildExtData(data []byte) []byte {
-	res := make([]byte, 0, len(data))
+	res := make([]byte, 0, len(data)+len(`"{"data":}"`))
 	res = append(res, []byte(`{"data":`)...)
 	res = append(res, data...)
 	res = append(res, []byte(`}`)...)
@@ -605,7 +461,6 @@ func buildExtData(data []byte) []byte {
 
 // ExtractBidderConfigFPD extracts bidder specific configs from req.ext.prebid.bidderconfig
 func ExtractBidderConfigFPD(reqExt *openrtb_ext.RequestExt) (map[openrtb_ext.BidderName]*openrtb_ext.ORTB2, error) {
-
 	fpd := make(map[openrtb_ext.BidderName]*openrtb_ext.ORTB2)
 	reqExtPrebid := reqExt.GetPrebid()
 	if reqExtPrebid != nil {
@@ -639,12 +494,10 @@ func ExtractBidderConfigFPD(reqExt *openrtb_ext.RequestExt) (map[openrtb_ext.Bid
 		reqExt.SetPrebid(reqExtPrebid)
 	}
 	return fpd, nil
-
 }
 
 // ExtractFPDForBidders extracts FPD data from request if specified
 func ExtractFPDForBidders(req *openrtb_ext.RequestWrapper) (map[openrtb_ext.BidderName]*ResolvedFirstPartyData, []error) {
-
 	reqExt, err := req.GetRequestExt()
 	if err != nil {
 		return nil, []error{err}
@@ -680,24 +533,4 @@ func ExtractFPDForBidders(req *openrtb_ext.RequestWrapper) (map[openrtb_ext.Bidd
 	}
 
 	return ResolveFPD(req.BidRequest, fbdBidderConfigData, globalFpd, openRtbGlobalFPD, biddersWithGlobalFPD)
-
-}
-
-func mergeContents(originalContent *openrtb2.Content, fpdBidderConfigContent json.RawMessage) (*openrtb2.Content, error) {
-	if originalContent == nil {
-		return unmarshalJSONToContent(fpdBidderConfigContent)
-	}
-	originalContentBytes, err := json.Marshal(originalContent)
-	if err != nil {
-		return nil, err
-	}
-	newFinalContentBytes, err := jsonpatch.MergePatch(originalContentBytes, fpdBidderConfigContent)
-	if err != nil {
-		return nil, err
-	}
-	newFinalContent, err := unmarshalJSONToContent(newFinalContentBytes)
-	if err != nil {
-		return nil, err
-	}
-	return newFinalContent, nil
 }
diff --git a/floors/enforce.go b/floors/enforce.go
index 8bfc31c3..1f50dd2b 100644
--- a/floors/enforce.go
+++ b/floors/enforce.go
@@ -24,7 +24,7 @@ func Enforce(bidRequestWrapper *openrtb_ext.RequestWrapper, seatBids map[openrtb
 	}
 
 	if !isPriceFloorsEnabled(account, bidRequestWrapper) {
-		return seatBids, []error{errors.New("Floors feature is disabled at account or in the request")}, rejectedBids
+		return seatBids, nil, rejectedBids
 	}
 
 	if isSignalingSkipped(requestExt) || !isValidImpBidFloorPresent(bidRequestWrapper.BidRequest.Imp) {
diff --git a/gdpr/impl.go b/gdpr/impl.go
index 9f9b47cd..4869a84d 100644
--- a/gdpr/impl.go
+++ b/gdpr/impl.go
@@ -245,7 +245,17 @@ func (p *permissionsImpl) parseVendor(ctx context.Context, vendorID uint16, cons
 		return
 	}
 
-	vendorList, err := p.fetchVendorList(ctx, parsedConsent.VendorListVersion())
+	policyVersion := parsedConsent.TCFPolicyVersion()
+	specVersion, err := getSpecVersion(policyVersion)
+	if err != nil {
+		err = &ErrorMalformedConsent{
+			Consent: consent,
+			Cause:   err,
+		}
+		return
+	}
+
+	vendorList, err := p.fetchVendorList(ctx, uint16(specVersion), parsedConsent.VendorListVersion())
 	if err != nil {
 		return
 	}
@@ -254,6 +264,20 @@ func (p *permissionsImpl) parseVendor(ctx context.Context, vendorID uint16, cons
 	return
 }
 
+// getSpecVersion looks at the TCF policy version and determines the corresponding GVL specification
+// version that should be used to calculate legal basis
+func getSpecVersion(policyVersion uint8) (uint16, error) {
+	var specVersion uint16 = 3
+
+	if policyVersion > 4 {
+		return 0, fmt.Errorf("invalid TCF policy version %d", policyVersion)
+	}
+	if policyVersion < 4 {
+		specVersion = 2
+	}
+	return specVersion, nil
+}
+
 // AllowHostCookies represents a GDPR permissions policy with host cookie syncing always allowed
 type AllowHostCookies struct {
 	*permissionsImpl
diff --git a/gdpr/vendorlist-fetching.go b/gdpr/vendorlist-fetching.go
index 864cf00f..da6bdbae 100644
--- a/gdpr/vendorlist-fetching.go
+++ b/gdpr/vendorlist-fetching.go
@@ -18,8 +18,8 @@ import (
 	"golang.org/x/net/context/ctxhttp"
 )
 
-type saveVendors func(uint16, api.VendorList)
-type VendorListFetcher func(ctx context.Context, id uint16) (vendorlist.VendorList, error)
+type saveVendors func(uint16, uint16, api.VendorList)
+type VendorListFetcher func(ctx context.Context, specVersion uint16, listVersion uint16) (vendorlist.VendorList, error)
 
 // This file provides the vendorlist-fetching function for Prebid Server.
 //
@@ -27,7 +27,7 @@ type VendorListFetcher func(ctx context.Context, id uint16) (vendorlist.VendorLi
 //
 // Nothing in this file is exported. Public APIs can be found in gdpr.go
 
-func NewVendorListFetcher(initCtx context.Context, cfg config.GDPR, client *http.Client, urlMaker func(uint16) string) VendorListFetcher {
+func NewVendorListFetcher(initCtx context.Context, cfg config.GDPR, client *http.Client, urlMaker func(uint16, uint16) string) VendorListFetcher {
 	cacheSave, cacheLoad := newVendorListCache()
 
 	preloadContext, cancel := context.WithTimeout(initCtx, cfg.Timeouts.InitTimeout())
@@ -35,50 +35,62 @@ func NewVendorListFetcher(initCtx context.Context, cfg config.GDPR, client *http
 	preloadCache(preloadContext, client, urlMaker, cacheSave)
 
 	saveOneRateLimited := newOccasionalSaver(cfg.Timeouts.ActiveTimeout())
-	return func(ctx context.Context, vendorListVersion uint16) (vendorlist.VendorList, error) {
+	return func(ctx context.Context, specVersion, listVersion uint16) (vendorlist.VendorList, error) {
 		// Attempt To Load From Cache
-		if list := cacheLoad(vendorListVersion); list != nil {
+		if list := cacheLoad(specVersion, listVersion); list != nil {
 			return list, nil
 		}
 
 		// Attempt To Download
 		// - May not add to cache immediately.
-		saveOneRateLimited(ctx, client, urlMaker(vendorListVersion), cacheSave)
+		saveOneRateLimited(ctx, client, urlMaker(specVersion, listVersion), cacheSave)
 
 		// Attempt To Load From Cache Again
 		// - May have been added by the call to saveOneRateLimited.
-		if list := cacheLoad(vendorListVersion); list != nil {
+		if list := cacheLoad(specVersion, listVersion); list != nil {
 			return list, nil
 		}
 
 		// Give Up
-		return nil, makeVendorListNotFoundError(vendorListVersion)
+		return nil, makeVendorListNotFoundError(specVersion, listVersion)
 	}
 }
 
-func makeVendorListNotFoundError(vendorListVersion uint16) error {
-	return fmt.Errorf("gdpr vendor list version %d does not exist, or has not been loaded yet. Try again in a few minutes", vendorListVersion)
+func makeVendorListNotFoundError(specVersion, listVersion uint16) error {
+	return fmt.Errorf("gdpr vendor list spec version %d list version %d does not exist, or has not been loaded yet. Try again in a few minutes", specVersion, listVersion)
 }
 
 // preloadCache saves all the known versions of the vendor list for future use.
-func preloadCache(ctx context.Context, client *http.Client, urlMaker func(uint16) string, saver saveVendors) {
-	latestVersion := saveOne(ctx, client, urlMaker(0), saver)
-
-	// The GVL for TCF2 has no vendors defined in its first version. It's very unlikely to be used, so don't preload it.
-	firstVersionToLoad := uint16(2)
+func preloadCache(ctx context.Context, client *http.Client, urlMaker func(uint16, uint16) string, saver saveVendors) {
+	versions := [2]struct {
+		specVersion      uint16
+		firstListVersion uint16
+	}{
+		{
+			specVersion:      2,
+			firstListVersion: 2, // The GVL for TCF2 has no vendors defined in its first version. It's very unlikely to be used, so don't preload it.
+		},
+		{
+			specVersion:      3,
+			firstListVersion: 1,
+		},
+	}
+	for _, v := range versions {
+		latestVersion := saveOne(ctx, client, urlMaker(v.specVersion, 0), saver)
 
-	for i := firstVersionToLoad; i < latestVersion; i++ {
-		saveOne(ctx, client, urlMaker(i), saver)
+		for i := v.firstListVersion; i < latestVersion; i++ {
+			saveOne(ctx, client, urlMaker(v.specVersion, i), saver)
+		}
 	}
 }
 
 // Make a URL which can be used to fetch a given version of the Global Vendor List. If the version is 0,
 // this will fetch the latest version.
-func VendorListURLMaker(vendorListVersion uint16) string {
-	if vendorListVersion == 0 {
-		return "https://vendor-list.consensu.org/v2/vendor-list.json"
+func VendorListURLMaker(specVersion, listVersion uint16) string {
+	if listVersion == 0 {
+		return "https://vendor-list.consensu.org/v" + strconv.Itoa(int(specVersion)) + "/vendor-list.json"
 	}
-	return "https://vendor-list.consensu.org/v2/archives/vendor-list-v" + strconv.Itoa(int(vendorListVersion)) + ".json"
+	return "https://vendor-list.consensu.org/v" + strconv.Itoa(int(specVersion)) + "/archives/vendor-list-v" + strconv.Itoa(int(listVersion)) + ".json"
 }
 
 // newOccasionalSaver returns a wrapped version of saveOne() which only activates every few minutes.
@@ -133,19 +145,21 @@ func saveOne(ctx context.Context, client *http.Client, url string, saver saveVen
 		return 0
 	}
 
-	saver(newList.Version(), newList)
+	saver(newList.SpecVersion(), newList.Version(), newList)
 	return newList.Version()
 }
 
-func newVendorListCache() (save func(vendorListVersion uint16, list api.VendorList), load func(vendorListVersion uint16) api.VendorList) {
+func newVendorListCache() (save func(specVersion, listVersion uint16, list api.VendorList), load func(specVersion, listVersion uint16) api.VendorList) {
 	cache := &sync.Map{}
 
-	save = func(vendorListVersion uint16, list api.VendorList) {
-		cache.Store(vendorListVersion, list)
+	save = func(specVersion uint16, listVersion uint16, list api.VendorList) {
+		key := fmt.Sprint(specVersion) + "-" + fmt.Sprint(listVersion)
+		cache.Store(key, list)
 	}
 
-	load = func(vendorListVersion uint16) api.VendorList {
-		list, ok := cache.Load(vendorListVersion)
+	load = func(specVersion, listVersion uint16) api.VendorList {
+		key := fmt.Sprint(specVersion) + "-" + fmt.Sprint(listVersion)
+		list, ok := cache.Load(key)
 		if ok {
 			return list.(vendorlist.VendorList)
 		}
diff --git a/go.mod b/go.mod
index 14aae8de..3833ef3d 100644
--- a/go.mod
+++ b/go.mod
@@ -19,7 +19,7 @@ require (
 	github.com/lib/pq v1.10.4
 	github.com/mitchellh/copystructure v1.2.0
 	github.com/pkg/errors v0.9.1
-	github.com/prebid/go-gdpr v1.11.0
+	github.com/prebid/go-gdpr v1.12.0
 	github.com/prebid/go-gpp v0.1.1
 	github.com/prebid/openrtb/v19 v19.0.0
 	github.com/prometheus/client_golang v1.12.1
diff --git a/go.sum b/go.sum
index 8e64faa6..e1226ab1 100644
--- a/go.sum
+++ b/go.sum
@@ -394,6 +394,8 @@ github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndr
 github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
 github.com/prebid/go-gdpr v1.11.0 h1:QbMjscuw3Ul0mDVWeMy5tP0Kii6lmTSSVhV6fm8rY9s=
 github.com/prebid/go-gdpr v1.11.0/go.mod h1:mPZAdkRxn+iuSjaUuJAi9+0SppBOdM1PCzv/55UH3pY=
+github.com/prebid/go-gdpr v1.12.0 h1:OrjQ7Uc+lCRYaOirQ48jjG/PBMvZsKNAaRTgzxN6iZ0=
+github.com/prebid/go-gdpr v1.12.0/go.mod h1:mPZAdkRxn+iuSjaUuJAi9+0SppBOdM1PCzv/55UH3pY=
 github.com/prebid/go-gpp v0.1.1 h1:uTMJ+eHmKWL9WvDuxFT4LDoOeJW1yOsfWITqi49ZuY0=
 github.com/prebid/go-gpp v0.1.1/go.mod h1:b0TLoVln+HXFD9L9xeimxIH3FN8WDKPJ42auslxEkow=
 github.com/prebid/openrtb/v19 v19.0.0 h1:NA7okrg7KcvL5wEg6yI0mAyujpyfkC8XSQr3h5ocN88=
diff --git a/hooks/repo.go b/hooks/repo.go
index 033a3f8f..40276701 100644
--- a/hooks/repo.go
+++ b/hooks/repo.go
@@ -2,6 +2,7 @@ package hooks
 
 import (
 	"fmt"
+
 	"github.com/prebid/prebid-server/hooks/hookstage"
 )
 
@@ -50,7 +51,7 @@ type hookRepository struct {
 	auctionResponseHooks         map[string]hookstage.AuctionResponse
 }
 
-func (r *hookRepository) GetEntrypointHook(id string) (h hookstage.Entrypoint, ok bool) {
+func (r *hookRepository) GetEntrypointHook(id string) (hookstage.Entrypoint, bool) {
 	return getHook(r.entrypointHooks, id)
 }
 
diff --git a/openrtb_ext/imp_sa_lunamedia.go b/openrtb_ext/imp_sa_lunamedia.go
index cb99b0ac..5b2d8a10 100644
--- a/openrtb_ext/imp_sa_lunamedia.go
+++ b/openrtb_ext/imp_sa_lunamedia.go
@@ -2,5 +2,5 @@ package openrtb_ext
 
 type ExtImpSaLunamedia struct {
 	Key  string `json:"key"`
-	Type string `json:"type,omitempty"`
+	Type string `json:"type"`
 }
diff --git a/openrtb_ext/request.go b/openrtb_ext/request.go
index 25e22805..adf00fcd 100644
--- a/openrtb_ext/request.go
+++ b/openrtb_ext/request.go
@@ -115,9 +115,9 @@ type Config struct {
 }
 
 type ORTB2 struct { //First party data
-	Site map[string]json.RawMessage `json:"site,omitempty"`
-	App  map[string]json.RawMessage `json:"app,omitempty"`
-	User map[string]json.RawMessage `json:"user,omitempty"`
+	Site json.RawMessage `json:"site,omitempty"`
+	App  json.RawMessage `json:"app,omitempty"`
+	User json.RawMessage `json:"user,omitempty"`
 }
 
 type ExtRequestCurrency struct {
@@ -375,14 +375,7 @@ func (erp *ExtRequestPrebid) Clone() *ExtRequestPrebid {
 		for i, bc := range erp.BidderConfigs {
 			clonedBidderConfig := BidderConfig{Bidders: sliceutil.Clone(bc.Bidders)}
 			if bc.Config != nil {
-				config := &Config{}
-				if bc.Config.ORTB2 != nil {
-					config.ORTB2 = &ORTB2{
-						Site: maputil.Clone(bc.Config.ORTB2.Site),
-						App:  maputil.Clone(bc.Config.ORTB2.App),
-						User: maputil.Clone(bc.Config.ORTB2.User),
-					}
-				}
+				config := &Config{ORTB2: ptrutil.Clone(bc.Config.ORTB2)}
 				clonedBidderConfig.Config = config
 			}
 			clone.BidderConfigs[i] = clonedBidderConfig
diff --git a/server/listener.go b/server/listener.go
index 065cb2de..43917ac0 100644
--- a/server/listener.go
+++ b/server/listener.go
@@ -38,7 +38,7 @@ func (l *monitorableConnection) Close() error {
 	return err
 }
 
-func (ln *monitorableListener) Accept() (c net.Conn, err error) {
+func (ln *monitorableListener) Accept() (net.Conn, error) {
 	tc, err := ln.Listener.Accept()
 	if err != nil {
 		glog.Errorf("Error accepting connection: %v", err)
diff --git a/static/bidder-info/lunamedia.yaml b/static/bidder-info/lunamedia.yaml
index 79e19a43..ef34143e 100644
--- a/static/bidder-info/lunamedia.yaml
+++ b/static/bidder-info/lunamedia.yaml
@@ -1,4 +1,4 @@
-endpoint: "http://api.lunamedia.io/xp/get?pubid={{.PublisherID}}"
+endpoint: "http://rtb.lunamedia.live/?pid={{.PublisherID}}"
 maintainer:
   email: "josh@lunamedia.io"
 capabilities:
@@ -6,13 +6,11 @@ capabilities:
     mediaTypes:
     - banner
     - video
-    
   app:
     mediaTypes:
     - banner
     - video
 userSync:
   iframe:
-    url: "https://api.lunamedia.io/xp/user-sync?redirect={{.RedirectURL}}"
-    userMacro: "$UID"
-
+    url: "https://sync.lunamedia.live/psync?t=s&e=0&cb={{.RedirectURL}}"
+    userMacro: "%USER_ID%"
diff --git a/static/bidder-info/pubmatic.yaml b/static/bidder-info/pubmatic.yaml
index 9841e899..0a8283b4 100644
--- a/static/bidder-info/pubmatic.yaml
+++ b/static/bidder-info/pubmatic.yaml
@@ -21,3 +21,5 @@ userSync:
   redirect:
     url: "https://image8.pubmatic.com/AdServer/ImgSync?p=159706&gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&us_privacy={{.USPrivacy}}&pu={{.RedirectURL}}"
     userMacro: "#PMUID"
+openrtb:
+  gpp-supported: true
diff --git a/static/bidder-info/smartadserver.yaml b/static/bidder-info/smartadserver.yaml
index 48e88d94..d861ed7f 100644
--- a/static/bidder-info/smartadserver.yaml
+++ b/static/bidder-info/smartadserver.yaml
@@ -1,6 +1,6 @@
 endpoint: "https://ssb-global.smartadserver.com"
 maintainer:
-  email: "support@smartadserver.com"
+  email: "supply-partner-integration@equativ.com"
 gvlVendorID: 45
 capabilities:
   app:
@@ -16,4 +16,4 @@ capabilities:
 userSync:
   redirect:
     url: "https://ssbsync-global.smartadserver.com/api/sync?callerId=5&gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&us_privacy={{.USPrivacy}}&redirectUri={{.RedirectURL}}"
-    userMacro: "[ssb_sync_pid]"
\ No newline at end of file
+    userMacro: "[ssb_sync_pid]"
diff --git a/static/bidder-params/sa_lunamedia.json b/static/bidder-params/sa_lunamedia.json
index 51ca0909..90aa2fe6 100644
--- a/static/bidder-params/sa_lunamedia.json
+++ b/static/bidder-params/sa_lunamedia.json
@@ -13,5 +13,5 @@
         "enum": ["network", "publisher"]
       }
     },
-    "required": ["key"]
+    "required": ["key", "type"]
   }
\ No newline at end of file


Test patch:
diff --git a/adapters/appnexus/appnexustest/supplemental/multiple-member-ids.json b/adapters/appnexus/appnexustest/supplemental/multiple-member-ids.json
index 03a72fd0..2d9fe9ce 100644
--- a/adapters/appnexus/appnexustest/supplemental/multiple-member-ids.json
+++ b/adapters/appnexus/appnexustest/supplemental/multiple-member-ids.json
@@ -61,7 +61,7 @@
   "expectedBidResponses": [],
   "expectedMakeRequestsErrors": [
     {
-      "value": "All request.imp[i].ext.prebid.bidder.appnexus.member params must match. Request contained: [103 104]",
+      "value": "all request.imp[i].ext.prebid.bidder.appnexus.member params must match. Request contained member IDs 103 and 104",
       "comparison": "literal"
     }
   ]
diff --git a/adapters/lunamedia/lunamedia_test.go b/adapters/lunamedia/lunamedia_test.go
index ef109d92..961cd545 100644
--- a/adapters/lunamedia/lunamedia_test.go
+++ b/adapters/lunamedia/lunamedia_test.go
@@ -11,7 +11,7 @@ import (
 
 func TestJsonSamples(t *testing.T) {
 	bidder, buildErr := Builder(openrtb_ext.BidderLunaMedia, config.Adapter{
-		Endpoint: "http://api.lunamedia.io/xp/get?pubid={{.PublisherID}}"}, config.Server{ExternalUrl: "http://hosturl.com", GvlID: 1, DataCenter: "2"})
+		Endpoint: "http://rtb.lunamedia.live/?pid={{.PublisherID}}"}, config.Server{ExternalUrl: "http://hosturl.com", GvlID: 1, DataCenter: "2"})
 
 	if buildErr != nil {
 		t.Fatalf("Builder returned unexpected error %v", buildErr)
diff --git a/adapters/lunamedia/lunamediatest/exemplary/banner.json b/adapters/lunamedia/lunamediatest/exemplary/banner.json
index 3b5c417f..fa92565b 100644
--- a/adapters/lunamedia/lunamediatest/exemplary/banner.json
+++ b/adapters/lunamedia/lunamediatest/exemplary/banner.json
@@ -31,7 +31,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "http://api.lunamedia.io/xp/get?pubid=19f1b372c7548ec1fe734d2c9f8dc688",
+        "uri": "http://rtb.lunamedia.live/?pid=19f1b372c7548ec1fe734d2c9f8dc688",
         "body":{
           "id": "testid",
           "imp": [{
diff --git a/adapters/lunamedia/lunamediatest/exemplary/video.json b/adapters/lunamedia/lunamediatest/exemplary/video.json
index 82217373..834963ff 100644
--- a/adapters/lunamedia/lunamediatest/exemplary/video.json
+++ b/adapters/lunamedia/lunamediatest/exemplary/video.json
@@ -24,7 +24,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "http://api.lunamedia.io/xp/get?pubid=19f1b372c7548ec1fe734d2c9f8dc688",
+        "uri": "http://rtb.lunamedia.live/?pid=19f1b372c7548ec1fe734d2c9f8dc688",
         "body":{
           "id": "testid",
           "imp": [{
diff --git a/adapters/lunamedia/lunamediatest/supplemental/compat.json b/adapters/lunamedia/lunamediatest/supplemental/compat.json
index 5b84d3a5..7fc713e0 100644
--- a/adapters/lunamedia/lunamediatest/supplemental/compat.json
+++ b/adapters/lunamedia/lunamediatest/supplemental/compat.json
@@ -23,7 +23,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "http://api.lunamedia.io/xp/get?pubid=19f1b372c7548ec1fe734d2c9f8dc688",
+        "uri": "http://rtb.lunamedia.live/?pid=19f1b372c7548ec1fe734d2c9f8dc688",
         "body":{
           "id": "testid",
           "imp": [{
diff --git a/adapters/lunamedia/lunamediatest/supplemental/responseCode.json b/adapters/lunamedia/lunamediatest/supplemental/responseCode.json
index 739af044..075d121a 100644
--- a/adapters/lunamedia/lunamediatest/supplemental/responseCode.json
+++ b/adapters/lunamedia/lunamediatest/supplemental/responseCode.json
@@ -33,7 +33,7 @@
   "httpCalls": [
       {
           "expectedRequest": {
-              "uri": "http://api.lunamedia.io/xp/get?pubid=yu",
+              "uri": "http://rtb.lunamedia.live/?pid=yu",
               "body": {
                   "id": "testid",
                   "imp": [
diff --git a/adapters/lunamedia/lunamediatest/supplemental/responsebid.json b/adapters/lunamedia/lunamediatest/supplemental/responsebid.json
index e9d8c3c5..f8c5b67a 100644
--- a/adapters/lunamedia/lunamediatest/supplemental/responsebid.json
+++ b/adapters/lunamedia/lunamediatest/supplemental/responsebid.json
@@ -33,7 +33,7 @@
   "httpCalls": [
       {
           "expectedRequest": {
-              "uri": "http://api.lunamedia.io/xp/get?pubid=yu",
+              "uri": "http://rtb.lunamedia.live/?pid=yu",
               "body": {
                   "id": "testid",
                   "imp": [
diff --git a/adapters/lunamedia/lunamediatest/supplemental/site.json b/adapters/lunamedia/lunamediatest/supplemental/site.json
index 81d71554..b2610cec 100644
--- a/adapters/lunamedia/lunamediatest/supplemental/site.json
+++ b/adapters/lunamedia/lunamediatest/supplemental/site.json
@@ -33,7 +33,7 @@
   "httpCalls": [
       {
           "expectedRequest": {
-              "uri": "http://api.lunamedia.io/xp/get?pubid=19f1b372c7548ec1fe734d2c9f8dc688",
+              "uri": "http://rtb.lunamedia.live/?pid=19f1b372c7548ec1fe734d2c9f8dc688",
               "body": {
                   "id": "testid",
                   "imp": [
diff --git a/adapters/sa_lunamedia/params_test.go b/adapters/sa_lunamedia/params_test.go
index bf7a1f49..070c9774 100644
--- a/adapters/sa_lunamedia/params_test.go
+++ b/adapters/sa_lunamedia/params_test.go
@@ -9,7 +9,6 @@ import (
 
 var validParams = []string{
 	`{ "key": "2", "type": "network"}`,
-	`{ "key": "1"}`,
 	`{ "key": "33232", "type": "publisher"}`,
 }
 
@@ -36,6 +35,7 @@ var invalidParams = []string{
 	`{ "anyparam": "anyvalue" }`,
 	`{ "type": "network" }`,
 	`{ "key": "asddsfd", "type": "any"}`,
+	`{ "key": "1"}`,
 }
 
 func TestInvalidParams(t *testing.T) {
diff --git a/exchange/exchange_test.go b/exchange/exchange_test.go
index 962b1dd9..511fd84b 100644
--- a/exchange/exchange_test.go
+++ b/exchange/exchange_test.go
@@ -7,6 +7,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"math"
 	"net/http"
 	"net/http/httptest"
 	"os"
@@ -3188,7 +3189,7 @@ func TestCategoryMappingBidderNameNoCategories(t *testing.T) {
 		includeWinners:   true,
 	}
 
-	requestExt.Prebid.Targeting.DurationRangeSec = []int{15, 30}
+	requestExt.Prebid.Targeting.DurationRangeSec = []int{30, 10, 25, 5, 20, 50}
 
 	adapterBids := make(map[openrtb_ext.BidderName]*entities.PbsOrtbSeatBid)
 
@@ -3197,8 +3198,8 @@ func TestCategoryMappingBidderNameNoCategories(t *testing.T) {
 	bid1 := openrtb2.Bid{ID: "bid_id1", ImpID: "imp_id1", Price: 10.0000, Cat: cats1, W: 1, H: 1}
 	bid2 := openrtb2.Bid{ID: "bid_id2", ImpID: "imp_id2", Price: 12.0000, Cat: cats2, W: 1, H: 1}
 
-	bid1_1 := entities.PbsOrtbBid{&bid1, nil, "video", nil, &openrtb_ext.ExtBidPrebidVideo{Duration: 30}, nil, nil, 0, false, "", 10.0000, "USD", ""}
-	bid1_2 := entities.PbsOrtbBid{&bid2, nil, "video", nil, &openrtb_ext.ExtBidPrebidVideo{Duration: 30}, nil, nil, 0, false, "", 12.0000, "USD", ""}
+	bid1_1 := entities.PbsOrtbBid{&bid1, nil, "video", nil, &openrtb_ext.ExtBidPrebidVideo{Duration: 17}, nil, nil, 0, false, "", 10.0000, "USD", ""}
+	bid1_2 := entities.PbsOrtbBid{&bid2, nil, "video", nil, &openrtb_ext.ExtBidPrebidVideo{Duration: 8}, nil, nil, 0, false, "", 12.0000, "USD", ""}
 
 	innerBids1 := []*entities.PbsOrtbBid{
 		&bid1_1,
@@ -3220,8 +3221,8 @@ func TestCategoryMappingBidderNameNoCategories(t *testing.T) {
 
 	assert.NoError(t, err, "Category mapping error should be empty")
 	assert.Empty(t, rejections, "There should be 0 bid rejection messages")
-	assert.Equal(t, "10.00_30s_bidder1", bidCategory["bid_id1"], "Category mapping doesn't match")
-	assert.Equal(t, "12.00_30s_bidder2", bidCategory["bid_id2"], "Category mapping doesn't match")
+	assert.Equal(t, "10.00_20s_bidder1", bidCategory["bid_id1"], "Category mapping doesn't match")
+	assert.Equal(t, "12.00_10s_bidder2", bidCategory["bid_id2"], "Category mapping doesn't match")
 	assert.Len(t, adapterBids[bidderName1].Bids, 1, "Bidders number doesn't match")
 	assert.Len(t, adapterBids[bidderName2].Bids, 1, "Bidders number doesn't match")
 	assert.Len(t, bidCategory, 2, "Bidders category mapping doesn't match")
@@ -3287,7 +3288,7 @@ func TestBidRejectionErrors(t *testing.T) {
 			},
 			duration: 70,
 			expectedRejections: []string{
-				"bid rejected [bid ID: bid_id1] reason: Bid duration exceeds maximum allowed",
+				"bid rejected [bid ID: bid_id1] reason: bid duration exceeds maximum allowed",
 			},
 		},
 		{
@@ -5641,6 +5642,77 @@ func TestNilAuctionRequest(t *testing.T) {
 	assert.Nil(t, err)
 }
 
+func TestSelectNewDuration(t *testing.T) {
+	type testInput struct {
+		dur       int
+		durRanges []int
+	}
+	type testOutput struct {
+		dur int
+		err error
+	}
+	testCases := []struct {
+		desc     string
+		in       testInput
+		expected testOutput
+	}{
+		{
+			desc: "nil duration range array, don't expect error",
+			in: testInput{
+				dur:       1,
+				durRanges: nil,
+			},
+			expected: testOutput{1, nil},
+		},
+		{
+			desc: "empty duration range array, don't expect error",
+			in: testInput{
+				dur:       1,
+				durRanges: []int{},
+			},
+			expected: testOutput{1, nil},
+		},
+		{
+			desc: "all duration range array elements less than duration, expect error",
+			in: testInput{
+				dur:       5,
+				durRanges: []int{-1, 0, 1, 2, 3},
+			},
+			expected: testOutput{5, errors.New("bid duration exceeds maximum allowed")},
+		},
+		{
+			desc: "all duration range array elements greater than duration, expect smallest element in durRanges and nil error",
+			in: testInput{
+				dur:       5,
+				durRanges: []int{9, math.MaxInt32, 8},
+			},
+			expected: testOutput{8, nil},
+		},
+		{
+			desc: "some array elements greater than duration, expect the value greater than dur that is closest in value.",
+			in: testInput{
+				dur:       5,
+				durRanges: []int{math.MaxInt32, -3, 7, 2},
+			},
+			expected: testOutput{7, nil},
+		},
+		{
+			desc: "an entry in the duration range array is equal to duration, expect its value in return.",
+			in: testInput{
+				dur:       5,
+				durRanges: []int{-3, math.MaxInt32, 5, 7},
+			},
+			expected: testOutput{5, nil},
+		},
+	}
+	for _, tc := range testCases {
+		newDur, err := findDurationRange(tc.in.dur, tc.in.durRanges)
+
+		assert.Equal(t, tc.expected.dur, newDur, tc.desc)
+		assert.Equal(t, tc.expected.err, err, tc.desc)
+	}
+}
+
 func TestSetSeatNonBid(t *testing.T) {
 	type args struct {
 		bidResponseExt *openrtb_ext.ExtBidResponse
diff --git a/exchange/utils_test.go b/exchange/utils_test.go
index 6fe0e55f..8bb82fe4 100644
--- a/exchange/utils_test.go
+++ b/exchange/utils_test.go
@@ -39,8 +39,8 @@ func (p *permissionsMock) BidderSyncAllowed(ctx context.Context, bidder openrtb_
 	return true, nil
 }
 
-func (p *permissionsMock) AuctionActivitiesAllowed(ctx context.Context, bidderCoreName openrtb_ext.BidderName, bidder openrtb_ext.BidderName) (permissions gdpr.AuctionPermissions, err error) {
-	permissions = gdpr.AuctionPermissions{
+func (p *permissionsMock) AuctionActivitiesAllowed(ctx context.Context, bidderCoreName openrtb_ext.BidderName, bidder openrtb_ext.BidderName) (gdpr.AuctionPermissions, error) {
+	permissions := gdpr.AuctionPermissions{
 		PassGeo: p.passGeo,
 		PassID:  p.passID,
 	}
diff --git a/firstpartydata/extmerger_test.go b/firstpartydata/extmerger_test.go
new file mode 100644
index 00000000..784163ac
--- /dev/null
+++ b/firstpartydata/extmerger_test.go
@@ -0,0 +1,109 @@
+package firstpartydata
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/prebid/prebid-server/util/sliceutil"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestExtMerger(t *testing.T) {
+	t.Run("nil", func(t *testing.T) {
+		merger := extMerger{ext: nil, snapshot: json.RawMessage(`{"a":1}`)}
+		assert.NoError(t, merger.Merge())
+		assert.Nil(t, merger.ext)
+	})
+
+	testCases := []struct {
+		name          string
+		givenOriginal json.RawMessage
+		givenFPD      json.RawMessage
+		expectedExt   json.RawMessage
+		expectedErr   string
+	}{
+		{
+			name:          "both-populated",
+			givenOriginal: json.RawMessage(`{"a":1,"b":2}`),
+			givenFPD:      json.RawMessage(`{"b":200,"c":3}`),
+			expectedExt:   json.RawMessage(`{"a":1,"b":200,"c":3}`),
+		},
+		{
+			name:          "both-nil",
+			givenFPD:      nil,
+			givenOriginal: nil,
+			expectedExt:   nil,
+		},
+		{
+			name:          "both-empty",
+			givenOriginal: json.RawMessage(`{}`),
+			givenFPD:      json.RawMessage(`{}`),
+			expectedExt:   json.RawMessage(`{}`),
+		},
+		{
+			name:          "ext-nil",
+			givenOriginal: json.RawMessage(`{"b":2}`),
+			givenFPD:      nil,
+			expectedExt:   json.RawMessage(`{"b":2}`),
+		},
+		{
+			name:          "ext-empty",
+			givenOriginal: json.RawMessage(`{"b":2}`),
+			givenFPD:      json.RawMessage(`{}`),
+			expectedExt:   json.RawMessage(`{"b":2}`),
+		},
+		{
+			name:          "ext-malformed",
+			givenOriginal: json.RawMessage(`{"b":2}`),
+			givenFPD:      json.RawMessage(`malformed`),
+			expectedErr:   "invalid first party data ext",
+		},
+		{
+			name:          "snapshot-nil",
+			givenOriginal: nil,
+			givenFPD:      json.RawMessage(`{"a":1}`),
+			expectedExt:   json.RawMessage(`{"a":1}`),
+		},
+		{
+			name:          "snapshot-empty",
+			givenOriginal: json.RawMessage(`{}`),
+			givenFPD:      json.RawMessage(`{"a":1}`),
+			expectedExt:   json.RawMessage(`{"a":1}`),
+		},
+		{
+			name:          "snapshot-malformed",
+			givenOriginal: json.RawMessage(`malformed`),
+			givenFPD:      json.RawMessage(`{"a":1}`),
+			expectedErr:   "invalid request ext",
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.name, func(t *testing.T) {
+			// Initialize A Ext Raw Message For Testing
+			simulatedExt := json.RawMessage(sliceutil.Clone(test.givenOriginal))
+
+			// Begin Tracking
+			var merger extMerger
+			merger.Track(&simulatedExt)
+
+			// Unmarshal
+			simulatedExt.UnmarshalJSON(test.givenFPD)
+
+			// Merge
+			actualErr := merger.Merge()
+
+			if test.expectedErr == "" {
+				assert.NoError(t, actualErr, "error")
+
+				if test.expectedExt == nil {
+					assert.Nil(t, simulatedExt, "json")
+				} else {
+					assert.JSONEq(t, string(test.expectedExt), string(simulatedExt), "json")
+				}
+			} else {
+				assert.EqualError(t, actualErr, test.expectedErr, "error")
+			}
+		})
+	}
+}
diff --git a/firstpartydata/first_party_data_test.go b/firstpartydata/first_party_data_test.go
index 2712e79f..4c9cd7ad 100644
--- a/firstpartydata/first_party_data_test.go
+++ b/firstpartydata/first_party_data_test.go
@@ -3,16 +3,17 @@ package firstpartydata
 import (
 	"encoding/json"
 	"os"
+	"reflect"
 	"testing"
 
 	"github.com/prebid/openrtb/v19/openrtb2"
 	"github.com/prebid/prebid-server/errortypes"
 	"github.com/prebid/prebid-server/openrtb_ext"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestExtractGlobalFPD(t *testing.T) {
-
 	testCases := []struct {
 		description string
 		input       openrtb_ext.RequestWrapper
@@ -255,7 +256,6 @@ func TestExtractGlobalFPD(t *testing.T) {
 }
 
 func TestExtractOpenRtbGlobalFPD(t *testing.T) {
-
 	testCases := []struct {
 		description     string
 		input           openrtb2.BidRequest
@@ -467,100 +467,86 @@ func TestExtractOpenRtbGlobalFPD(t *testing.T) {
 }
 
 func TestExtractBidderConfigFPD(t *testing.T) {
+	testPath := "tests/extractbidderconfigfpd"
 
-	if specFiles, err := os.ReadDir("./tests/extractbidderconfigfpd"); err == nil {
-		for _, specFile := range specFiles {
-			fileName := "./tests/extractbidderconfigfpd/" + specFile.Name()
+	tests, err := os.ReadDir(testPath)
+	require.NoError(t, err, "Cannot Discover Tests")
 
-			fpdFile, err := loadFpdFile(fileName)
-			if err != nil {
-				t.Errorf("Unable to load file: %s", fileName)
-			}
-			var extReq openrtb_ext.ExtRequestPrebid
-			err = json.Unmarshal(fpdFile.InputRequestData, &extReq)
-			if err != nil {
-				t.Errorf("Unable to unmarshal input request: %s", fileName)
-			}
-			reqExt := openrtb_ext.RequestExt{}
-			reqExt.SetPrebid(&extReq)
-			fpdData, err := ExtractBidderConfigFPD(&reqExt)
+	for _, test := range tests {
+		t.Run(test.Name(), func(t *testing.T) {
+			filePath := testPath + "/" + test.Name()
 
-			if len(fpdFile.ValidationErrors) > 0 {
-				assert.Equal(t, err.Error(), fpdFile.ValidationErrors[0].Message, "Incorrect first party data error message")
-				continue
-			}
+			fpdFile, err := loadFpdFile(filePath)
+			require.NoError(t, err, "Cannot Load Test")
 
-			assert.Nil(t, reqExt.GetPrebid().BidderConfigs, "Bidder specific FPD config should be removed from request")
+			givenRequestExtPrebid := &openrtb_ext.ExtRequestPrebid{}
+			err = json.Unmarshal(fpdFile.InputRequestData, givenRequestExtPrebid)
+			require.NoError(t, err, "Cannot Load Test Conditions")
 
-			assert.Nil(t, err, "No error should be returned")
-			assert.Equal(t, len(fpdFile.BidderConfigFPD), len(fpdData), "Incorrect fpd data")
+			testRequest := &openrtb_ext.RequestExt{}
+			testRequest.SetPrebid(givenRequestExtPrebid)
 
-			for bidderName, bidderFPD := range fpdFile.BidderConfigFPD {
+			// run test
+			results, err := ExtractBidderConfigFPD(testRequest)
 
-				if bidderFPD.Site != nil {
-					resSite := fpdData[bidderName].Site
-					for k, v := range bidderFPD.Site {
-						assert.NotNil(t, resSite[k], "Property is not found in result site")
-						assert.JSONEq(t, string(v), string(resSite[k]), "site is incorrect")
-					}
+			// assert errors
+			if len(fpdFile.ValidationErrors) > 0 {
+				require.EqualError(t, err, fpdFile.ValidationErrors[0].Message, "Expected Error Not Received")
+			} else {
+				require.NoError(t, err, "Error Not Expected")
+				assert.Nil(t, testRequest.GetPrebid().BidderConfigs, "Bidder specific FPD config should be removed from request")
+			}
+
+			// assert fpd (with normalization for nicer looking tests)
+			for bidderName, expectedFPD := range fpdFile.BidderConfigFPD {
+				if expectedFPD.App != nil {
+					assert.JSONEq(t, string(expectedFPD.App), string(results[bidderName].App), "app is incorrect")
 				} else {
-					assert.Nil(t, fpdData[bidderName].Site, "Result site should be also nil")
+					assert.Nil(t, results[bidderName].App, "app expected to be nil")
 				}
 
-				if bidderFPD.App != nil {
-					resApp := fpdData[bidderName].App
-					for k, v := range bidderFPD.App {
-						assert.NotNil(t, resApp[k], "Property is not found in result app")
-						assert.JSONEq(t, string(v), string(resApp[k]), "app is incorrect")
-					}
+				if expectedFPD.Site != nil {
+					assert.JSONEq(t, string(expectedFPD.Site), string(results[bidderName].Site), "site is incorrect")
 				} else {
-					assert.Nil(t, fpdData[bidderName].App, "Result app should be also nil")
+					assert.Nil(t, results[bidderName].Site, "site expected to be nil")
 				}
 
-				if bidderFPD.User != nil {
-					resUser := fpdData[bidderName].User
-					for k, v := range bidderFPD.User {
-						assert.NotNil(t, resUser[k], "Property is not found in result user")
-						assert.JSONEq(t, string(v), string(resUser[k]), "site is incorrect")
-					}
+				if expectedFPD.User != nil {
+					assert.JSONEq(t, string(expectedFPD.User), string(results[bidderName].User), "user is incorrect")
 				} else {
-					assert.Nil(t, fpdData[bidderName].User, "Result user should be also nil")
+					assert.Nil(t, results[bidderName].User, "user expected to be nil")
 				}
 			}
-		}
+		})
 	}
 }
 
 func TestResolveFPD(t *testing.T) {
+	testPath := "tests/resolvefpd"
 
-	if specFiles, err := os.ReadDir("./tests/resolvefpd"); err == nil {
-		for _, specFile := range specFiles {
-			fileName := "./tests/resolvefpd/" + specFile.Name()
+	tests, err := os.ReadDir(testPath)
+	require.NoError(t, err, "Cannot Discover Tests")
 
-			fpdFile, err := loadFpdFile(fileName)
-			if err != nil {
-				t.Errorf("Unable to load file: %s", fileName)
-			}
+	for _, test := range tests {
+		t.Run(test.Name(), func(t *testing.T) {
+			filePath := testPath + "/" + test.Name()
 
-			var inputReq openrtb2.BidRequest
-			err = json.Unmarshal(fpdFile.InputRequestData, &inputReq)
-			if err != nil {
-				t.Errorf("Unable to unmarshal input request: %s", fileName)
-			}
+			fpdFile, err := loadFpdFile(filePath)
+			require.NoError(t, err, "Cannot Load Test")
 
-			var inputReqCopy openrtb2.BidRequest
-			err = json.Unmarshal(fpdFile.InputRequestData, &inputReqCopy)
-			if err != nil {
-				t.Errorf("Unable to unmarshal input request: %s", fileName)
-			}
+			request := &openrtb2.BidRequest{}
+			err = json.Unmarshal(fpdFile.InputRequestData, &request)
+			require.NoError(t, err, "Cannot Load Request")
 
-			var outputReq openrtb2.BidRequest
+			originalRequest := &openrtb2.BidRequest{}
+			err = json.Unmarshal(fpdFile.InputRequestData, &originalRequest)
+			require.NoError(t, err, "Cannot Load Request")
+
+			outputReq := &openrtb2.BidRequest{}
 			err = json.Unmarshal(fpdFile.OutputRequestData, &outputReq)
-			if err != nil {
-				t.Errorf("Unable to unmarshal output request: %s", fileName)
-			}
+			require.NoError(t, err, "Cannot Load Output Request")
 
-			reqExtFPD := make(map[string][]byte, 3)
+			reqExtFPD := make(map[string][]byte)
 			reqExtFPD["site"] = fpdFile.GlobalFPD["site"]
 			reqExtFPD["app"] = fpdFile.GlobalFPD["app"]
 			reqExtFPD["user"] = fpdFile.GlobalFPD["user"]
@@ -572,7 +558,7 @@ func TestResolveFPD(t *testing.T) {
 				var siteConData []openrtb2.Data
 				err = json.Unmarshal(reqFPDSiteContentData, &siteConData)
 				if err != nil {
-					t.Errorf("Unable to unmarshal site.content.data: %s", fileName)
+					t.Errorf("Unable to unmarshal site.content.data:")
 				}
 				reqFPD[siteContentDataKey] = siteConData
 			}
@@ -582,7 +568,7 @@ func TestResolveFPD(t *testing.T) {
 				var appConData []openrtb2.Data
 				err = json.Unmarshal(reqFPDAppContentData, &appConData)
 				if err != nil {
-					t.Errorf("Unable to unmarshal app.content.data: %s", fileName)
+					t.Errorf("Unable to unmarshal app.content.data: ")
 				}
 				reqFPD[appContentDataKey] = appConData
 			}
@@ -592,7 +578,7 @@ func TestResolveFPD(t *testing.T) {
 				var userData []openrtb2.Data
 				err = json.Unmarshal(reqFPDUserData, &userData)
 				if err != nil {
-					t.Errorf("Unable to unmarshal app.content.data: %s", fileName)
+					t.Errorf("Unable to unmarshal app.content.data: ")
 				}
 				reqFPD[userDataKey] = userData
 			}
@@ -601,10 +587,11 @@ func TestResolveFPD(t *testing.T) {
 				fpdFile.BidderConfigFPD["appnexus"] = &openrtb_ext.ORTB2{}
 			}
 
-			resultFPD, errL := ResolveFPD(&inputReq, fpdFile.BidderConfigFPD, reqExtFPD, reqFPD, []string{"appnexus"})
+			// run test
+			resultFPD, errL := ResolveFPD(request, fpdFile.BidderConfigFPD, reqExtFPD, reqFPD, []string{"appnexus"})
 
 			if len(errL) == 0 {
-				assert.Equal(t, inputReq, inputReqCopy, "Original request should not be modified")
+				assert.Equal(t, request, originalRequest, "Original request should not be modified")
 
 				bidderFPD := resultFPD["appnexus"]
 
@@ -622,6 +609,7 @@ func TestResolveFPD(t *testing.T) {
 					expectedAppExt := outputReq.App.Ext
 					bidderFPD.App.Ext = nil
 					outputReq.App.Ext = nil
+
 					assert.JSONEq(t, string(expectedAppExt), string(resAppExt), "app.ext is incorrect")
 
 					assert.Equal(t, outputReq.App, bidderFPD.App, "App is incorrect")
@@ -638,13 +626,11 @@ func TestResolveFPD(t *testing.T) {
 			} else {
 				assert.ElementsMatch(t, errL, fpdFile.ValidationErrors, "Incorrect first party data warning message")
 			}
-
-		}
+		})
 	}
 }
 
 func TestExtractFPDForBidders(t *testing.T) {
-
 	if specFiles, err := os.ReadDir("./tests/extractfpdforbidders"); err == nil {
 		for _, specFile := range specFiles {
 			fileName := "./tests/extractfpdforbidders/" + specFile.Name()
@@ -758,663 +744,1186 @@ type fpdFile struct {
 }
 
 func TestResolveUser(t *testing.T) {
-
-	fpdConfigUser := make(map[string]json.RawMessage, 0)
-	fpdConfigUser["id"] = []byte(`"fpdConfigUserId"`)
-	fpdConfigUser[yobKey] = []byte(`1980`)
-	fpdConfigUser[genderKey] = []byte(`"M"`)
-	fpdConfigUser[keywordsKey] = []byte(`"fpdConfigUserKeywords"`)
-	fpdConfigUser[dataKey] = []byte(`[{"id":"UserDataId1", "name":"UserDataName1"}, {"id":"UserDataId2", "name":"UserDataName2"}]`)
-	fpdConfigUser["ext"] = []byte(`{"data":{"fpdConfigUserExt": 123}}`)
-
-	bidRequestUser := &openrtb2.User{
-		ID:       "bidRequestUserId",
-		Yob:      1990,
-		Gender:   "F",
-		Keywords: "bidRequestUserKeywords",
-	}
-
-	globalFPD := make(map[string][]byte, 0)
-	globalFPD[userKey] = []byte(`{"globalFPDUserData": "globalFPDUserDataValue"}`)
-
-	openRtbGlobalFPD := make(map[string][]openrtb2.Data, 0)
-	openRtbGlobalFPD[userDataKey] = []openrtb2.Data{
-		{ID: "openRtbGlobalFPDUserDataId1", Name: "openRtbGlobalFPDUserDataName1"},
-		{ID: "openRtbGlobalFPDUserDataId2", Name: "openRtbGlobalFPDUserDataName2"},
+	testCases := []struct {
+		description      string
+		fpdConfig        *openrtb_ext.ORTB2
+		bidRequestUser   *openrtb2.User
+		globalFPD        map[string][]byte
+		openRtbGlobalFPD map[string][]openrtb2.Data
+		expectedUser     *openrtb2.User
+		expectedError    string
+	}{
+		{
+			description:  "FPD config and bid request user are not specified",
+			expectedUser: nil,
+		},
+		{
+			description:  "FPD config user only is specified",
+			fpdConfig:    &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test"}`)},
+			expectedUser: &openrtb2.User{ID: "test"},
+		},
+		{
+			description:    "FPD config and bid request user are specified",
+			fpdConfig:      &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestUser: &openrtb2.User{ID: "test2"},
+			expectedUser:   &openrtb2.User{ID: "test1"},
+		},
+		{
+			description:    "FPD config, bid request and global fpd user are specified, no input user ext",
+			fpdConfig:      &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestUser: &openrtb2.User{ID: "test2"},
+			globalFPD:      map[string][]byte{userKey: []byte(`{"globalFPDUserData": "globalFPDUserDataValue"}`)},
+			expectedUser:   &openrtb2.User{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDUserData":"globalFPDUserDataValue"}}`)},
+		},
+		{
+			description:    "FPD config, bid request user with ext and global fpd user are specified, no input user ext",
+			fpdConfig:      &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestUser: &openrtb2.User{ID: "test2", Ext: json.RawMessage(`{"test":{"inputFPDUserData":"inputFPDUserDataValue"}}`)},
+			globalFPD:      map[string][]byte{userKey: []byte(`{"globalFPDUserData": "globalFPDUserDataValue"}`)},
+			expectedUser:   &openrtb2.User{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDUserData":"globalFPDUserDataValue"},"test":{"inputFPDUserData":"inputFPDUserDataValue"}}`)},
+		},
+		{
+			description:    "FPD config, bid request and global fpd user are specified, with input user ext.data",
+			fpdConfig:      &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestUser: &openrtb2.User{ID: "test2", Ext: json.RawMessage(`{"data":{"inputFPDUserData":"inputFPDUserDataValue"}}`)},
+			globalFPD:      map[string][]byte{userKey: []byte(`{"globalFPDUserData": "globalFPDUserDataValue"}`)},
+			expectedUser:   &openrtb2.User{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDUserData":"globalFPDUserDataValue","inputFPDUserData":"inputFPDUserDataValue"}}`)},
+		},
+		{
+			description:    "FPD config, bid request and global fpd user are specified, with input user ext.data malformed",
+			fpdConfig:      &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestUser: &openrtb2.User{ID: "test2", Ext: json.RawMessage(`{"data":{"inputFPDUserData":"inputFPDUserDataValue"}}`)},
+			globalFPD:      map[string][]byte{userKey: []byte(`malformed`)},
+			expectedError:  "Invalid JSON Patch",
+		},
+		{
+			description:    "bid request and openrtb global fpd user are specified, no input user ext",
+			bidRequestUser: &openrtb2.User{ID: "test2"},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{userDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedUser: &openrtb2.User{ID: "test2", Data: []openrtb2.Data{
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+		},
+		{
+			description:    "fpd config user, bid request and openrtb global fpd user are specified, no input user ext",
+			fpdConfig:      &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestUser: &openrtb2.User{ID: "test2"},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{userDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedUser: &openrtb2.User{ID: "test1", Data: []openrtb2.Data{
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+		},
+		{
+			description:    "fpd config user with ext, bid request and openrtb global fpd user are specified, no input user ext",
+			fpdConfig:      &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1", "ext":{"test":1}}`)},
+			bidRequestUser: &openrtb2.User{ID: "test2"},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{userDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedUser: &openrtb2.User{ID: "test1", Data: []openrtb2.Data{
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			},
+				Ext: json.RawMessage(`{"test":1}`)},
+		},
+		{
+			description:    "fpd config user with ext, bid requestuser with ext and openrtb global fpd user are specified, no input user ext",
+			fpdConfig:      &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1", "ext":{"test":1}}`)},
+			bidRequestUser: &openrtb2.User{ID: "test2", Ext: json.RawMessage(`{"test":2, "key": "value"}`)},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{userDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedUser: &openrtb2.User{ID: "test1", Data: []openrtb2.Data{
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			},
+				Ext: json.RawMessage(`{"key":"value","test":1}`)},
+		},
+		{
+			description:    "fpd config user with malformed ext, bid requestuser with ext and openrtb global fpd user are specified, no input user ext",
+			fpdConfig:      &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1", "ext":{malformed}}`)},
+			bidRequestUser: &openrtb2.User{ID: "test2", Ext: json.RawMessage(`{"test":2, "key": "value"}`)},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{userDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedUser: &openrtb2.User{ID: "test1", Data: []openrtb2.Data{
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			},
+				Ext: json.RawMessage(`{"key":"value","test":1}`),
+			},
+			expectedError: "invalid character 'm' looking for beginning of object key string",
+		},
 	}
+	for _, test := range testCases {
+		t.Run(test.description, func(t *testing.T) {
+			resultUser, err := resolveUser(test.fpdConfig, test.bidRequestUser, test.globalFPD, test.openRtbGlobalFPD, "bidderA")
 
-	expectedUser := &openrtb2.User{
-		ID:       "bidRequestUserId",
-		Yob:      1980,
-		Gender:   "M",
-		Keywords: "fpdConfigUserKeywords",
-		Data: []openrtb2.Data{
-			{ID: "openRtbGlobalFPDUserDataId1", Name: "openRtbGlobalFPDUserDataName1"},
-			{ID: "openRtbGlobalFPDUserDataId2", Name: "openRtbGlobalFPDUserDataName2"},
-			{ID: "UserDataId1", Name: "UserDataName1"},
-			{ID: "UserDataId2", Name: "UserDataName2"},
-		},
+			if test.expectedError == "" {
+				assert.NoError(t, err, "unexpected error returned")
+				assert.Equal(t, test.expectedUser, resultUser, "Result user is incorrect")
+			} else {
+				assert.EqualError(t, err, test.expectedError, "expected error incorrect")
+			}
+		})
 	}
+}
 
+func TestResolveSite(t *testing.T) {
 	testCases := []struct {
-		description       string
-		bidRequestUserExt []byte
-		expectedUserExt   string
+		description      string
+		fpdConfig        *openrtb_ext.ORTB2
+		bidRequestSite   *openrtb2.Site
+		globalFPD        map[string][]byte
+		openRtbGlobalFPD map[string][]openrtb2.Data
+		expectedSite     *openrtb2.Site
+		expectedError    string
 	}{
 		{
-			description:       "bid request user.ext is nil",
-			bidRequestUserExt: nil,
-			expectedUserExt: `{"data":{
-									"fpdConfigUserExt":123,
-									"globalFPDUserData":"globalFPDUserDataValue",
-									"id":"fpdConfigUserId"
-									}
-							}`,
-		},
-		{
-			description:       "bid request user.ext is not nil",
-			bidRequestUserExt: []byte(`{"bidRequestUserExt": 1234}`),
-			expectedUserExt: `{"data":{
-									"fpdConfigUserExt":123,
-									"globalFPDUserData":"globalFPDUserDataValue",
-									"id":"fpdConfigUserId"
-									},
-								"bidRequestUserExt":1234
-							}`,
+			description:  "FPD config and bid request site are not specified",
+			expectedSite: nil,
+		},
+		{
+			description:   "FPD config site only is specified",
+			fpdConfig:     &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test"}`)},
+			expectedError: "incorrect First Party Data for bidder bidderA: Site object is not defined in request, but defined in FPD config",
+		},
+		{
+			description:    "FPD config and bid request site are specified",
+			fpdConfig:      &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestSite: &openrtb2.Site{ID: "test2"},
+			expectedSite:   &openrtb2.Site{ID: "test1"},
+		},
+		{
+			description:    "FPD config, bid request and global fpd site are specified, no input site ext",
+			fpdConfig:      &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestSite: &openrtb2.Site{ID: "test2"},
+			globalFPD:      map[string][]byte{siteKey: []byte(`{"globalFPDSiteData": "globalFPDSiteDataValue"}`)},
+			expectedSite:   &openrtb2.Site{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDSiteData":"globalFPDSiteDataValue"}}`)},
+		},
+		{
+			description:    "FPD config, bid request site with ext and global fpd site are specified, no input site ext",
+			fpdConfig:      &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestSite: &openrtb2.Site{ID: "test2", Ext: json.RawMessage(`{"test":{"inputFPDSiteData":"inputFPDSiteDataValue"}}`)},
+			globalFPD:      map[string][]byte{siteKey: []byte(`{"globalFPDSiteData": "globalFPDSiteDataValue"}`)},
+			expectedSite:   &openrtb2.Site{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDSiteData":"globalFPDSiteDataValue"},"test":{"inputFPDSiteData":"inputFPDSiteDataValue"}}`)},
+		},
+		{
+			description:    "FPD config, bid request and global fpd site are specified, with input site ext.data",
+			fpdConfig:      &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestSite: &openrtb2.Site{ID: "test2", Ext: json.RawMessage(`{"data":{"inputFPDSiteData":"inputFPDSiteDataValue"}}`)},
+			globalFPD:      map[string][]byte{siteKey: []byte(`{"globalFPDSiteData": "globalFPDSiteDataValue"}`)},
+			expectedSite:   &openrtb2.Site{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDSiteData":"globalFPDSiteDataValue","inputFPDSiteData":"inputFPDSiteDataValue"}}`)},
+		},
+		{
+			description:    "FPD config, bid request and global fpd site are specified, with input site ext.data malformed",
+			fpdConfig:      &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestSite: &openrtb2.Site{ID: "test2", Ext: json.RawMessage(`{"data":{"inputFPDSiteData":"inputFPDSiteDataValue"}}`)},
+			globalFPD:      map[string][]byte{siteKey: []byte(`malformed`)},
+			expectedError:  "Invalid JSON Patch",
+		},
+		{
+			description:    "bid request and openrtb global fpd site are specified, no input site ext",
+			bidRequestSite: &openrtb2.Site{ID: "test2"},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{siteContentDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedSite: &openrtb2.Site{ID: "test2", Content: &openrtb2.Content{Data: []openrtb2.Data{
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}}},
+		},
+		{
+			description: "bid request with content and openrtb global fpd site are specified, no input site ext",
+			bidRequestSite: &openrtb2.Site{ID: "test2", Content: &openrtb2.Content{
+				ID: "InputSiteContentId",
+				Data: []openrtb2.Data{
+					{ID: "1", Name: "N1"},
+					{ID: "2", Name: "N2"},
+				},
+				Ext: json.RawMessage(`{"contentPresent":true}`),
+			}},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{siteContentDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedSite: &openrtb2.Site{ID: "test2", Content: &openrtb2.Content{
+				ID: "InputSiteContentId",
+				Data: []openrtb2.Data{
+					{ID: "DataId1", Name: "Name1"},
+					{ID: "DataId2", Name: "Name2"},
+				},
+				Ext: json.RawMessage(`{"contentPresent":true}`),
+			}},
+		},
+		{
+			description:    "fpd config site, bid request and openrtb global fpd site are specified, no input site ext",
+			fpdConfig:      &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestSite: &openrtb2.Site{ID: "test2"},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{siteContentDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedSite: &openrtb2.Site{ID: "test1", Content: &openrtb2.Content{Data: []openrtb2.Data{
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}}},
+		},
+		{
+			description:    "fpd config site with ext, bid request and openrtb global fpd site are specified, no input site ext",
+			fpdConfig:      &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1", "ext":{"test":1}}`)},
+			bidRequestSite: &openrtb2.Site{ID: "test2"},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{siteContentDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedSite: &openrtb2.Site{ID: "test1", Content: &openrtb2.Content{Data: []openrtb2.Data{
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+				Ext: json.RawMessage(`{"test":1}`)},
+		},
+		{
+			description:    "fpd config site with ext, bid request site with ext and openrtb global fpd site are specified, no input site ext",
+			fpdConfig:      &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1", "ext":{"test":1}}`)},
+			bidRequestSite: &openrtb2.Site{ID: "test2", Ext: json.RawMessage(`{"test":2, "key": "value"}`)},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{siteContentDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedSite: &openrtb2.Site{ID: "test1", Content: &openrtb2.Content{Data: []openrtb2.Data{
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+				Ext: json.RawMessage(`{"key":"value","test":1}`)},
+		},
+		{
+			description:    "fpd config site with malformed ext, bid request site with ext and openrtb global fpd site are specified, no input site ext",
+			fpdConfig:      &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1", "ext":{malformed}}`)},
+			bidRequestSite: &openrtb2.Site{ID: "test2", Ext: json.RawMessage(`{"test":2, "key": "value"}`)},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{siteContentDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedSite: &openrtb2.Site{ID: "test1", Content: &openrtb2.Content{Data: []openrtb2.Data{
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+				Ext: json.RawMessage(`{"key":"value","test":1}`),
+			},
+			expectedError: "invalid character 'm' looking for beginning of object key string",
 		},
 	}
-
 	for _, test := range testCases {
-		bidRequestUser.Ext = test.bidRequestUserExt
-
-		fpdConfigUser := make(map[string]json.RawMessage, 0)
-		fpdConfigUser["id"] = []byte(`"fpdConfigUserId"`)
-		fpdConfigUser[yobKey] = []byte(`1980`)
-		fpdConfigUser[genderKey] = []byte(`"M"`)
-		fpdConfigUser[keywordsKey] = []byte(`"fpdConfigUserKeywords"`)
-		fpdConfigUser[dataKey] = []byte(`[{"id":"UserDataId1", "name":"UserDataName1"}, {"id":"UserDataId2", "name":"UserDataName2"}]`)
-		fpdConfigUser["ext"] = []byte(`{"data":{"fpdConfigUserExt": 123}}`)
-		fpdConfig := &openrtb_ext.ORTB2{User: fpdConfigUser}
-
-		resultUser, err := resolveUser(fpdConfig, bidRequestUser, globalFPD, openRtbGlobalFPD, "appnexus")
-		assert.NoError(t, err, "No error should be returned")
-
-		assert.JSONEq(t, test.expectedUserExt, string(resultUser.Ext), "Result user.Ext is incorrect")
-		resultUser.Ext = nil
-		assert.Equal(t, expectedUser, resultUser, "Result user is incorrect")
-	}
-
-}
+		t.Run(test.description, func(t *testing.T) {
+			resultSite, err := resolveSite(test.fpdConfig, test.bidRequestSite, test.globalFPD, test.openRtbGlobalFPD, "bidderA")
 
-func TestResolveUserNilValues(t *testing.T) {
-	resultUser, err := resolveUser(nil, nil, nil, nil, "appnexus")
-	assert.NoError(t, err, "No error should be returned")
-	assert.Nil(t, resultUser, "Result user should be nil")
+			if test.expectedError == "" {
+				assert.NoError(t, err, "unexpected error returned")
+				assert.Equal(t, test.expectedSite, resultSite, "Result site is incorrect")
+			} else {
+				assert.EqualError(t, err, test.expectedError, "expected error incorrect")
+			}
+		})
+	}
 }
 
-func TestMergeUsers(t *testing.T) {
-
-	originalUser := &openrtb2.User{
-		ID:       "bidRequestUserId",
-		Yob:      1980,
-		Gender:   "M",
-		Keywords: "fpdConfigUserKeywords",
-		Data: []openrtb2.Data{
-			{ID: "openRtbGlobalFPDUserDataId1", Name: "openRtbGlobalFPDUserDataName1"},
-			{ID: "openRtbGlobalFPDUserDataId2", Name: "openRtbGlobalFPDUserDataName2"},
+func TestResolveApp(t *testing.T) {
+	testCases := []struct {
+		description      string
+		fpdConfig        *openrtb_ext.ORTB2
+		bidRequestApp    *openrtb2.App
+		globalFPD        map[string][]byte
+		openRtbGlobalFPD map[string][]openrtb2.Data
+		expectedApp      *openrtb2.App
+		expectedError    string
+	}{
+		{
+			description: "FPD config and bid request app are not specified",
+			expectedApp: nil,
 		},
-		Ext: []byte(`{"bidRequestUserExt": 1234}`),
-	}
-	fpdConfigUser := make(map[string]json.RawMessage, 0)
-	fpdConfigUser["id"] = []byte(`"fpdConfigUserId"`)
-	fpdConfigUser[yobKey] = []byte(`1980`)
-	fpdConfigUser[genderKey] = []byte(`"M"`)
-	fpdConfigUser[keywordsKey] = []byte(`"fpdConfigUserKeywords"`)
-	fpdConfigUser[dataKey] = []byte(`[{"id":"UserDataId1", "name":"UserDataName1"}, {"id":"UserDataId2", "name":"UserDataName2"}]`)
-	fpdConfigUser["ext"] = []byte(`{"data":{"fpdConfigUserExt": 123}}`)
-
-	resultUser, err := mergeUsers(originalUser, fpdConfigUser)
-	assert.NoError(t, err, "No error should be returned")
-
-	expectedUserExt := `{"bidRequestUserExt":1234,
-						 "data":{
-							"fpdConfigUserExt":123,
-							"id":"fpdConfigUserId"}
-						 }`
-	assert.JSONEq(t, expectedUserExt, string(resultUser.Ext), "Result user.Ext is incorrect")
-	resultUser.Ext = nil
-
-	expectedUser := openrtb2.User{
-		ID:       "bidRequestUserId",
-		Yob:      1980,
-		Gender:   "M",
-		Keywords: "fpdConfigUserKeywords",
-		Data: []openrtb2.Data{
-			{ID: "openRtbGlobalFPDUserDataId1", Name: "openRtbGlobalFPDUserDataName1"},
-			{ID: "openRtbGlobalFPDUserDataId2", Name: "openRtbGlobalFPDUserDataName2"},
-			{ID: "UserDataId1", Name: "UserDataName1"},
-			{ID: "UserDataId2", Name: "UserDataName2"},
+		{
+			description:   "FPD config app only is specified",
+			fpdConfig:     &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test"}`)},
+			expectedError: "incorrect First Party Data for bidder bidderA: App object is not defined in request, but defined in FPD config",
+		},
+		{
+			description:   "FPD config and bid request app are specified",
+			fpdConfig:     &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestApp: &openrtb2.App{ID: "test2"},
+			expectedApp:   &openrtb2.App{ID: "test1"},
+		},
+		{
+			description:   "FPD config, bid request and global fpd app are specified, no input app ext",
+			fpdConfig:     &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestApp: &openrtb2.App{ID: "test2"},
+			globalFPD:     map[string][]byte{appKey: []byte(`{"globalFPDAppData": "globalFPDAppDataValue"}`)},
+			expectedApp:   &openrtb2.App{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDAppData":"globalFPDAppDataValue"}}`)},
+		},
+		{
+			description:   "FPD config, bid request app with ext and global fpd app are specified, no input app ext",
+			fpdConfig:     &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestApp: &openrtb2.App{ID: "test2", Ext: json.RawMessage(`{"test":{"inputFPDAppData":"inputFPDAppDataValue"}}`)},
+			globalFPD:     map[string][]byte{appKey: []byte(`{"globalFPDAppData": "globalFPDAppDataValue"}`)},
+			expectedApp:   &openrtb2.App{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDAppData":"globalFPDAppDataValue"},"test":{"inputFPDAppData":"inputFPDAppDataValue"}}`)},
+		},
+		{
+			description:   "FPD config, bid request and global fpd app are specified, with input app ext.data",
+			fpdConfig:     &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestApp: &openrtb2.App{ID: "test2", Ext: json.RawMessage(`{"data":{"inputFPDAppData":"inputFPDAppDataValue"}}`)},
+			globalFPD:     map[string][]byte{appKey: []byte(`{"globalFPDAppData": "globalFPDAppDataValue"}`)},
+			expectedApp:   &openrtb2.App{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDAppData":"globalFPDAppDataValue","inputFPDAppData":"inputFPDAppDataValue"}}`)},
+		},
+		{
+			description:   "FPD config, bid request and global fpd app are specified, with input app ext.data malformed",
+			fpdConfig:     &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestApp: &openrtb2.App{ID: "test2", Ext: json.RawMessage(`{"data":{"inputFPDAppData":"inputFPDAppDataValue"}}`)},
+			globalFPD:     map[string][]byte{appKey: []byte(`malformed`)},
+			expectedError: "Invalid JSON Patch",
+		},
+		{
+			description:   "bid request and openrtb global fpd app are specified, no input app ext",
+			bidRequestApp: &openrtb2.App{ID: "test2"},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{appContentDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedApp: &openrtb2.App{ID: "test2", Content: &openrtb2.Content{Data: []openrtb2.Data{
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}}},
+		},
+		{
+			description: "bid request with content and openrtb global fpd app are specified, no input app ext",
+			bidRequestApp: &openrtb2.App{ID: "test2", Content: &openrtb2.Content{
+				ID: "InputAppContentId",
+				Data: []openrtb2.Data{
+					{ID: "1", Name: "N1"},
+					{ID: "2", Name: "N2"},
+				},
+				Ext: json.RawMessage(`{"contentPresent":true}`),
+			}},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{appContentDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedApp: &openrtb2.App{ID: "test2", Content: &openrtb2.Content{
+				ID: "InputAppContentId",
+				Data: []openrtb2.Data{
+					{ID: "DataId1", Name: "Name1"},
+					{ID: "DataId2", Name: "Name2"},
+				},
+				Ext: json.RawMessage(`{"contentPresent":true}`),
+			}},
+		},
+		{
+			description:   "fpd config app, bid request and openrtb global fpd app are specified, no input app ext",
+			fpdConfig:     &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1"}`)},
+			bidRequestApp: &openrtb2.App{ID: "test2"},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{appContentDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedApp: &openrtb2.App{ID: "test1", Content: &openrtb2.Content{Data: []openrtb2.Data{
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}}},
+		},
+		{
+			description:   "fpd config app with ext, bid request and openrtb global fpd app are specified, no input app ext",
+			fpdConfig:     &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1", "ext":{"test":1}}`)},
+			bidRequestApp: &openrtb2.App{ID: "test2"},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{appContentDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedApp: &openrtb2.App{ID: "test1", Content: &openrtb2.Content{Data: []openrtb2.Data{
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+				Ext: json.RawMessage(`{"test":1}`)},
+		},
+		{
+			description:   "fpd config app with ext, bid request app with ext and openrtb global fpd app are specified, no input app ext",
+			fpdConfig:     &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1", "ext":{"test":1}}`)},
+			bidRequestApp: &openrtb2.App{ID: "test2", Ext: json.RawMessage(`{"test":2, "key": "value"}`)},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{appContentDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedApp: &openrtb2.App{ID: "test1", Content: &openrtb2.Content{Data: []openrtb2.Data{
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+				Ext: json.RawMessage(`{"key":"value","test":1}`)},
+		},
+		{
+			description:   "fpd config app with malformed ext, bid request app with ext and openrtb global fpd app are specified, no input app ext",
+			fpdConfig:     &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1", "ext":{malformed}}`)},
+			bidRequestApp: &openrtb2.App{ID: "test2", Ext: json.RawMessage(`{"test":2, "key": "value"}`)},
+			openRtbGlobalFPD: map[string][]openrtb2.Data{appContentDataKey: {
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+			expectedApp: &openrtb2.App{ID: "test1", Content: &openrtb2.Content{Data: []openrtb2.Data{
+				{ID: "DataId1", Name: "Name1"},
+				{ID: "DataId2", Name: "Name2"},
+			}},
+				Ext: json.RawMessage(`{"key":"value","test":1}`),
+			},
+			expectedError: "invalid character 'm' looking for beginning of object key string",
 		},
 	}
-	assert.Equal(t, expectedUser, resultUser, "Result user is incorrect")
-}
+	for _, test := range testCases {
+		t.Run(test.description, func(t *testing.T) {
+			resultApp, err := resolveApp(test.fpdConfig, test.bidRequestApp, test.globalFPD, test.openRtbGlobalFPD, "bidderA")
 
-func TestResolveExtension(t *testing.T) {
+			if test.expectedError == "" {
+				assert.NoError(t, err, "unexpected error returned")
+				assert.Equal(t, test.expectedApp, resultApp, "Result app is incorrect")
+			} else {
+				assert.EqualError(t, err, test.expectedError, "expected error incorrect")
+			}
+		})
+	}
+}
 
+func TestBuildExtData(t *testing.T) {
 	testCases := []struct {
 		description string
-		fpdConfig   map[string]json.RawMessage
-		originalExt json.RawMessage
-		expectedExt string
+		input       []byte
+		expectedRes string
 	}{
-		{description: "Fpd config with ext only",
-			fpdConfig:   map[string]json.RawMessage{"ext": json.RawMessage(`{"data":{"fpdConfigUserExt": 123}}`)},
-			originalExt: json.RawMessage(`{"bidRequestUserExt": 1234}`),
-			expectedExt: `{"bidRequestUserExt":1234, "data":{"fpdConfigUserExt":123}}`,
-		},
-		{description: "Fpd config with ext and another property",
-			fpdConfig:   map[string]json.RawMessage{"ext": json.RawMessage(`{"data":{"fpdConfigUserExt": 123}}`), "prebid": json.RawMessage(`{"prebidData":{"isPrebid": true}}`)},
-			originalExt: json.RawMessage(`{"bidRequestUserExt": 1234}`),
-			expectedExt: `{"bidRequestUserExt":1234, "data":{"fpdConfigUserExt":123, "prebid":{"prebidData":{"isPrebid": true}}}}`,
-		},
-		{description: "Fpd config empty",
-			fpdConfig:   nil,
-			originalExt: json.RawMessage(`{"bidRequestUserExt": 1234}`),
-			expectedExt: `{"bidRequestUserExt":1234}`,
-		},
-		{description: "Original ext empty",
-			fpdConfig:   map[string]json.RawMessage{"ext": json.RawMessage(`{"data":{"fpdConfigUserExt": 123}}`)},
-			originalExt: nil,
-			expectedExt: `{"data":{"ext":{"data":{"fpdConfigUserExt":123}}}}`,
+		{
+			description: "Input object with int value",
+			input:       []byte(`{"someData": 123}`),
+			expectedRes: `{"data": {"someData": 123}}`,
+		},
+		{
+			description: "Input object with bool value",
+			input:       []byte(`{"someData": true}`),
+			expectedRes: `{"data": {"someData": true}}`,
+		},
+		{
+			description: "Input object with string value",
+			input:       []byte(`{"someData": "true"}`),
+			expectedRes: `{"data": {"someData": "true"}}`,
+		},
+		{
+			description: "No input object",
+			input:       []byte(`{}`),
+			expectedRes: `{"data": {}}`,
+		},
+		{
+			description: "Input object with object value",
+			input:       []byte(`{"someData": {"moreFpdData": "fpddata"}}`),
+			expectedRes: `{"data": {"someData": {"moreFpdData": "fpddata"}}}`,
 		},
 	}
 
 	for _, test := range testCases {
-		resExt, err := resolveExtension(test.fpdConfig, test.originalExt)
-		assert.NoError(t, err, "No error should be returned")
-		assert.JSONEq(t, test.expectedExt, string(resExt), "result ext is incorrect")
+		actualRes := buildExtData(test.input)
+		assert.JSONEq(t, test.expectedRes, string(actualRes), "Incorrect result data")
 	}
 }
 
-func TestResolveSite(t *testing.T) {
-
-	fpdConfigSite := make(map[string]json.RawMessage, 0)
-	fpdConfigSite["id"] = []byte(`"fpdConfigSiteId"`)
-	fpdConfigSite[keywordsKey] = []byte(`"fpdConfigSiteKeywords"`)
-	fpdConfigSite[nameKey] = []byte(`"fpdConfigSiteName"`)
-	fpdConfigSite[pageKey] = []byte(`"fpdConfigSitePage"`)
-	fpdConfigSite[dataKey] = []byte(`[{"id":"SiteDataId1", "name":"SiteDataName1"}, {"id":"SiteDataId2", "name":"SiteDataName2"}]`)
-	fpdConfigSite["ext"] = []byte(`{"data":{"fpdConfigSiteExt": 123}}`)
-
-	bidRequestSite := &openrtb2.Site{
-		ID:       "bidRequestSiteId",
-		Keywords: "bidRequestSiteKeywords",
-		Name:     "bidRequestSiteName",
-		Page:     "bidRequestSitePage",
-		Content: &openrtb2.Content{
-			ID:      "bidRequestSiteContentId",
-			Episode: 4,
-			Data: []openrtb2.Data{
-				{ID: "bidRequestSiteContentDataId1", Name: "bidRequestSiteContentDataName1"},
-				{ID: "bidRequestSiteContentDataId2", Name: "bidRequestSiteContentDataName2"},
-			},
+func TestMergeUser(t *testing.T) {
+	testCases := []struct {
+		name         string
+		givenUser    openrtb2.User
+		givenFPD     json.RawMessage
+		expectedUser openrtb2.User
+		expectedErr  string
+	}{
+		{
+			name:         "empty",
+			givenUser:    openrtb2.User{},
+			givenFPD:     []byte(`{}`),
+			expectedUser: openrtb2.User{},
+		},
+		{
+			name:         "toplevel",
+			givenUser:    openrtb2.User{ID: "1"},
+			givenFPD:     []byte(`{"id":"2"}`),
+			expectedUser: openrtb2.User{ID: "2"},
+		},
+		{
+			name:         "toplevel-ext",
+			givenUser:    openrtb2.User{Ext: []byte(`{"a":1,"b":2}`)},
+			givenFPD:     []byte(`{"ext":{"b":100,"c":3}}`),
+			expectedUser: openrtb2.User{Ext: []byte(`{"a":1,"b":100,"c":3}`)},
+		},
+		{
+			name:        "toplevel-ext-err",
+			givenUser:   openrtb2.User{ID: "1", Ext: []byte(`malformed`)},
+			givenFPD:    []byte(`{"id":"2"}`),
+			expectedErr: "invalid request ext",
+		},
+		{
+			name:         "nested-geo",
+			givenUser:    openrtb2.User{Geo: &openrtb2.Geo{Lat: 1}},
+			givenFPD:     []byte(`{"geo":{"lat": 2}}`),
+			expectedUser: openrtb2.User{Geo: &openrtb2.Geo{Lat: 2}},
+		},
+		{
+			name:         "nested-geo-ext",
+			givenUser:    openrtb2.User{Geo: &openrtb2.Geo{Ext: []byte(`{"a":1,"b":2}`)}},
+			givenFPD:     []byte(`{"geo":{"ext":{"b":100,"c":3}}}`),
+			expectedUser: openrtb2.User{Geo: &openrtb2.Geo{Ext: []byte(`{"a":1,"b":100,"c":3}`)}},
+		},
+		{
+			name:         "toplevel-ext-and-nested-geo-ext",
+			givenUser:    openrtb2.User{Ext: []byte(`{"a":1,"b":2}`), Geo: &openrtb2.Geo{Ext: []byte(`{"a":10,"b":20}`)}},
+			givenFPD:     []byte(`{"ext":{"b":100,"c":3}, "geo":{"ext":{"b":100,"c":3}}}`),
+			expectedUser: openrtb2.User{Ext: []byte(`{"a":1,"b":100,"c":3}`), Geo: &openrtb2.Geo{Ext: []byte(`{"a":10,"b":100,"c":3}`)}},
+		},
+		{
+			name:        "nested-geo-ext-err",
+			givenUser:   openrtb2.User{Geo: &openrtb2.Geo{Ext: []byte(`malformed`)}},
+			givenFPD:    []byte(`{"geo":{"ext":{"b":100,"c":3}}}`),
+			expectedErr: "invalid request ext",
+		},
+		{
+			name:        "fpd-err",
+			givenUser:   openrtb2.User{ID: "1", Ext: []byte(`{"a":1}`)},
+			givenFPD:    []byte(`malformed`),
+			expectedErr: "invalid character 'm' looking for beginning of value",
 		},
 	}
 
-	globalFPD := make(map[string][]byte, 0)
-	globalFPD[siteKey] = []byte(`{"globalFPDSiteData": "globalFPDSiteDataValue"}`)
-
-	openRtbGlobalFPD := make(map[string][]openrtb2.Data, 0)
-	openRtbGlobalFPD[siteContentDataKey] = []openrtb2.Data{
-		{ID: "openRtbGlobalFPDSiteContentDataId1", Name: "openRtbGlobalFPDSiteContentDataName1"},
-		{ID: "openRtbGlobalFPDSiteContentDataId2", Name: "openRtbGlobalFPDSiteContentDataName2"},
-	}
+	for _, test := range testCases {
+		t.Run(test.name, func(t *testing.T) {
+			err := mergeUser(&test.givenUser, test.givenFPD)
 
-	expectedSite := &openrtb2.Site{
-		ID:       "bidRequestSiteId",
-		Keywords: "fpdConfigSiteKeywords",
-		Name:     "bidRequestSiteName",
-		Page:     "bidRequestSitePage",
-		Content: &openrtb2.Content{
-			ID:      "bidRequestSiteContentId",
-			Episode: 4,
-			Data: []openrtb2.Data{
-				{ID: "openRtbGlobalFPDSiteContentDataId1", Name: "openRtbGlobalFPDSiteContentDataName1"},
-				{ID: "openRtbGlobalFPDSiteContentDataId2", Name: "openRtbGlobalFPDSiteContentDataName2"},
-			},
-		},
+			if test.expectedErr == "" {
+				assert.NoError(t, err, "unexpected error returned")
+				assert.Equal(t, test.expectedUser, test.givenUser, "result user is incorrect")
+			} else {
+				assert.EqualError(t, err, test.expectedErr, "expected error incorrect")
+			}
+		})
 	}
+}
 
+func TestMergeApp(t *testing.T) {
 	testCases := []struct {
-		description       string
-		bidRequestSiteExt []byte
-		expectedSiteExt   string
-		siteContentNil    bool
+		name        string
+		givenApp    openrtb2.App
+		givenFPD    json.RawMessage
+		expectedApp openrtb2.App
+		expectedErr string
 	}{
 		{
-			description:       "bid request site.ext is nil",
-			bidRequestSiteExt: nil,
-			expectedSiteExt: `{"data":{
-									"data":[
-										{"id":"SiteDataId1","name":"SiteDataName1"},
-										{"id":"SiteDataId2","name":"SiteDataName2"}
-									],
-									"fpdConfigSiteExt":123,
-									"globalFPDSiteData":"globalFPDSiteDataValue",
-									"id":"fpdConfigSiteId"
-									}
-							}`,
-			siteContentNil: false,
-		},
-		{
-			description:       "bid request site.ext is not nil",
-			bidRequestSiteExt: []byte(`{"bidRequestSiteExt": 1234}`),
-			expectedSiteExt: `{"data":{
-									"data":[
-										{"id":"SiteDataId1","name":"SiteDataName1"},
-										{"id":"SiteDataId2","name":"SiteDataName2"}
-									],
-									"fpdConfigSiteExt":123,
-									"globalFPDSiteData":"globalFPDSiteDataValue",
-									"id":"fpdConfigSiteId"
-									},
-								"bidRequestSiteExt":1234
-							}`,
-			siteContentNil: false,
-		},
-		{
-			description:       "bid request site.content.data is nil ",
-			bidRequestSiteExt: []byte(`{"bidRequestSiteExt": 1234}`),
-			expectedSiteExt: `{"data":{
-									"data":[
-										{"id":"SiteDataId1","name":"SiteDataName1"},
-										{"id":"SiteDataId2","name":"SiteDataName2"}
-									],
-									"fpdConfigSiteExt":123,
-									"globalFPDSiteData":"globalFPDSiteDataValue",
-									"id":"fpdConfigSiteId"
-									},
-								"bidRequestSiteExt":1234
-							}`,
-			siteContentNil: true,
+			name:        "empty",
+			givenApp:    openrtb2.App{},
+			givenFPD:    []byte(`{}`),
+			expectedApp: openrtb2.App{},
+		},
+		{
+			name:        "toplevel",
+			givenApp:    openrtb2.App{ID: "1"},
+			givenFPD:    []byte(`{"id":"2"}`),
+			expectedApp: openrtb2.App{ID: "2"},
+		},
+		{
+			name:        "toplevel-ext",
+			givenApp:    openrtb2.App{Ext: []byte(`{"a":1,"b":2}`)},
+			givenFPD:    []byte(`{"ext":{"b":100,"c":3}}`),
+			expectedApp: openrtb2.App{Ext: []byte(`{"a":1,"b":100,"c":3}`)},
+		},
+		{
+			name:        "toplevel-ext-err",
+			givenApp:    openrtb2.App{ID: "1", Ext: []byte(`malformed`)},
+			givenFPD:    []byte(`{"id":"2"}`),
+			expectedErr: "invalid request ext",
+		},
+		{
+			name:        "nested-publisher",
+			givenApp:    openrtb2.App{Publisher: &openrtb2.Publisher{Name: "pub1"}},
+			givenFPD:    []byte(`{"publisher":{"name": "pub2"}}`),
+			expectedApp: openrtb2.App{Publisher: &openrtb2.Publisher{Name: "pub2"}},
+		},
+		{
+			name:        "nested-content",
+			givenApp:    openrtb2.App{Content: &openrtb2.Content{Title: "content1"}},
+			givenFPD:    []byte(`{"content":{"title": "content2"}}`),
+			expectedApp: openrtb2.App{Content: &openrtb2.Content{Title: "content2"}},
+		},
+		{
+			name:        "nested-content-producer",
+			givenApp:    openrtb2.App{Content: &openrtb2.Content{Title: "content1", Producer: &openrtb2.Producer{Name: "producer1"}}},
+			givenFPD:    []byte(`{"content":{"title": "content2", "producer":{"name":"producer2"}}}`),
+			expectedApp: openrtb2.App{Content: &openrtb2.Content{Title: "content2", Producer: &openrtb2.Producer{Name: "producer2"}}},
+		},
+		{
+			name:        "nested-content-network",
+			givenApp:    openrtb2.App{Content: &openrtb2.Content{Title: "content1", Network: &openrtb2.Network{Name: "network1"}}},
+			givenFPD:    []byte(`{"content":{"title": "content2", "network":{"name":"network2"}}}`),
+			expectedApp: openrtb2.App{Content: &openrtb2.Content{Title: "content2", Network: &openrtb2.Network{Name: "network2"}}},
+		},
+		{
+			name:        "nested-content-channel",
+			givenApp:    openrtb2.App{Content: &openrtb2.Content{Title: "content1", Channel: &openrtb2.Channel{Name: "channel1"}}},
+			givenFPD:    []byte(`{"content":{"title": "content2", "channel":{"name":"channel2"}}}`),
+			expectedApp: openrtb2.App{Content: &openrtb2.Content{Title: "content2", Channel: &openrtb2.Channel{Name: "channel2"}}},
+		},
+		{
+			name:        "nested-publisher-ext",
+			givenApp:    openrtb2.App{Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":1,"b":2}`)}},
+			givenFPD:    []byte(`{"publisher":{"ext":{"b":100,"c":3}}}`),
+			expectedApp: openrtb2.App{Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":1,"b":100,"c":3}`)}},
+		},
+		{
+			name:        "nested-content-ext",
+			givenApp:    openrtb2.App{Content: &openrtb2.Content{Ext: []byte(`{"a":1,"b":2}`)}},
+			givenFPD:    []byte(`{"content":{"ext":{"b":100,"c":3}}}`),
+			expectedApp: openrtb2.App{Content: &openrtb2.Content{Ext: []byte(`{"a":1,"b":100,"c":3}`)}},
+		},
+		{
+			name:        "nested-content-producer-ext",
+			givenApp:    openrtb2.App{Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":1,"b":2}`)}}},
+			givenFPD:    []byte(`{"content":{"producer":{"ext":{"b":100,"c":3}}}}`),
+			expectedApp: openrtb2.App{Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}},
+		},
+		{
+			name:        "nested-content-network-ext",
+			givenApp:    openrtb2.App{Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":1,"b":2}`)}}},
+			givenFPD:    []byte(`{"content":{"network":{"ext":{"b":100,"c":3}}}}`),
+			expectedApp: openrtb2.App{Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}},
+		},
+		{
+			name:        "nested-content-channel-ext",
+			givenApp:    openrtb2.App{Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":1,"b":2}`)}}},
+			givenFPD:    []byte(`{"content":{"channel":{"ext":{"b":100,"c":3}}}}`),
+			expectedApp: openrtb2.App{Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}},
+		},
+		{
+			name:        "toplevel-ext-and-nested-publisher-ext",
+			givenApp:    openrtb2.App{Ext: []byte(`{"a":1,"b":2}`), Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":10,"b":20}`)}},
+			givenFPD:    []byte(`{"ext":{"b":100,"c":3}, "publisher":{"ext":{"b":100,"c":3}}}`),
+			expectedApp: openrtb2.App{Ext: []byte(`{"a":1,"b":100,"c":3}`), Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":10,"b":100,"c":3}`)}},
+		},
+		{
+			name:        "toplevel-ext-and-nested-content-ext",
+			givenApp:    openrtb2.App{Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Ext: []byte(`{"a":10,"b":20}`)}},
+			givenFPD:    []byte(`{"ext":{"b":100,"c":3}, "content":{"ext":{"b":100,"c":3}}}`),
+			expectedApp: openrtb2.App{Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Ext: []byte(`{"a":10,"b":100,"c":3}`)}},
+		},
+		{
+			name:        "toplevel-ext-and-nested-content-producer-ext",
+			givenApp:    openrtb2.App{Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":10,"b":20}`)}}},
+			givenFPD:    []byte(`{"ext":{"b":100,"c":3}, "content":{"producer": {"ext":{"b":100,"c":3}}}}`),
+			expectedApp: openrtb2.App{Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}},
+		},
+		{
+			name:        "toplevel-ext-and-nested-content-network-ext",
+			givenApp:    openrtb2.App{Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":10,"b":20}`)}}},
+			givenFPD:    []byte(`{"ext":{"b":100,"c":3}, "content":{"network": {"ext":{"b":100,"c":3}}}}`),
+			expectedApp: openrtb2.App{Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}},
+		},
+		{
+			name:        "toplevel-ext-and-nested-content-channel-ext",
+			givenApp:    openrtb2.App{Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":10,"b":20}`)}}},
+			givenFPD:    []byte(`{"ext":{"b":100,"c":3}, "content":{"channel": {"ext":{"b":100,"c":3}}}}`),
+			expectedApp: openrtb2.App{Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}},
+		},
+		{
+			name:        "nested-publisher-ext-err",
+			givenApp:    openrtb2.App{Publisher: &openrtb2.Publisher{Ext: []byte(`malformed`)}},
+			givenFPD:    []byte(`{"publisher":{"ext":{"b":100,"c":3}}}`),
+			expectedErr: "invalid request ext",
+		},
+		{
+			name:        "nested-content-ext-err",
+			givenApp:    openrtb2.App{Content: &openrtb2.Content{Ext: []byte(`malformed`)}},
+			givenFPD:    []byte(`{"content":{"ext":{"b":100,"c":3}}}`),
+			expectedErr: "invalid request ext",
+		},
+		{
+			name:        "nested-content-producer-ext-err",
+			givenApp:    openrtb2.App{Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`malformed`)}}},
+			givenFPD:    []byte(`{"content":{"producer": {"ext":{"b":100,"c":3}}}}`),
+			expectedErr: "invalid request ext",
+		},
+		{
+			name:        "nested-content-network-ext-err",
+			givenApp:    openrtb2.App{Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`malformed`)}}},
+			givenFPD:    []byte(`{"content":{"network": {"ext":{"b":100,"c":3}}}}`),
+			expectedErr: "invalid request ext",
+		},
+		{
+			name:        "nested-content-channel-ext-err",
+			givenApp:    openrtb2.App{Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`malformed`)}}},
+			givenFPD:    []byte(`{"content":{"channelx": {"ext":{"b":100,"c":3}}}}`),
+			expectedErr: "invalid request ext",
+		},
+		{
+			name:        "fpd-err",
+			givenApp:    openrtb2.App{ID: "1", Ext: []byte(`{"a":1}`)},
+			givenFPD:    []byte(`malformed`),
+			expectedErr: "invalid character 'm' looking for beginning of value",
 		},
 	}
 
 	for _, test := range testCases {
-		if test.siteContentNil {
-			bidRequestSite.Content = nil
-			expectedSite.Content = &openrtb2.Content{Data: []openrtb2.Data{
-				{ID: "openRtbGlobalFPDSiteContentDataId1", Name: "openRtbGlobalFPDSiteContentDataName1"},
-				{ID: "openRtbGlobalFPDSiteContentDataId2", Name: "openRtbGlobalFPDSiteContentDataName2"},
-			}}
-		}
-
-		bidRequestSite.Ext = test.bidRequestSiteExt
-
-		fpdConfigSite := make(map[string]json.RawMessage, 0)
-		fpdConfigSite["id"] = []byte(`"fpdConfigSiteId"`)
-		fpdConfigSite[keywordsKey] = []byte(`"fpdConfigSiteKeywords"`)
-		fpdConfigSite[dataKey] = []byte(`[{"id":"SiteDataId1", "name":"SiteDataName1"}, {"id":"SiteDataId2", "name":"SiteDataName2"}]`)
-		fpdConfigSite["ext"] = []byte(`{"data":{"fpdConfigSiteExt": 123}}`)
-		fpdConfig := &openrtb_ext.ORTB2{Site: fpdConfigSite}
+		t.Run(test.name, func(t *testing.T) {
+			err := mergeApp(&test.givenApp, test.givenFPD)
 
-		resultSite, err := resolveSite(fpdConfig, bidRequestSite, globalFPD, openRtbGlobalFPD, "appnexus")
-		assert.NoError(t, err, "No error should be returned")
-
-		assert.JSONEq(t, test.expectedSiteExt, string(resultSite.Ext), "Result site.Ext is incorrect")
-		resultSite.Ext = nil
-		assert.Equal(t, expectedSite, resultSite, "Result site is incorrect")
+			if test.expectedErr == "" {
+				assert.NoError(t, err, "unexpected error returned")
+				assert.Equal(t, test.expectedApp, test.givenApp, " result app is incorrect")
+			} else {
+				assert.EqualError(t, err, test.expectedErr, "expected error incorrect")
+			}
+		})
 	}
-
-}
-
-func TestResolveSiteNilValues(t *testing.T) {
-	resultSite, err := resolveSite(nil, nil, nil, nil, "appnexus")
-	assert.NoError(t, err, "No error should be returned")
-	assert.Nil(t, resultSite, "Result site should be nil")
 }
 
-func TestResolveSiteBadInput(t *testing.T) {
-	fpdConfigSite := make(map[string]json.RawMessage, 0)
-	fpdConfigSite["id"] = []byte(`"fpdConfigSiteId"`)
-	fpdConfig := &openrtb_ext.ORTB2{Site: fpdConfigSite}
-
-	resultSite, err := resolveSite(fpdConfig, nil, nil, nil, "appnexus")
-	assert.Error(t, err, "Error should be returned")
-	assert.Equal(t, "incorrect First Party Data for bidder appnexus: Site object is not defined in request, but defined in FPD config", err.Error(), "Incorrect error message")
-	assert.Nil(t, resultSite, "Result site should be nil")
-}
-
-func TestMergeSites(t *testing.T) {
-
-	originalSite := &openrtb2.Site{
-		ID:         "bidRequestSiteId",
-		Keywords:   "bidRequestSiteKeywords",
-		Page:       "bidRequestSitePage",
-		Name:       "bidRequestSiteName",
-		Domain:     "bidRequestSiteDomain",
-		Cat:        []string{"books1", "magazines1"},
-		SectionCat: []string{"books2", "magazines2"},
-		PageCat:    []string{"books3", "magazines3"},
-		Search:     "bidRequestSiteSearch",
-		Ref:        "bidRequestSiteRef",
-		Content: &openrtb2.Content{
-			Title: "bidRequestSiteContentTitle",
-			Data: []openrtb2.Data{
-				{ID: "openRtbGlobalFPDSiteDataId1", Name: "openRtbGlobalFPDSiteDataName1"},
-				{ID: "openRtbGlobalFPDSiteDataId2", Name: "openRtbGlobalFPDSiteDataName2"},
-			},
+func TestMergeSite(t *testing.T) {
+	testCases := []struct {
+		name         string
+		givenSite    openrtb2.Site
+		givenFPD     json.RawMessage
+		expectedSite openrtb2.Site
+		expectedErr  string
+	}{
+		{
+			name:        "empty",
+			givenSite:   openrtb2.Site{},
+			givenFPD:    []byte(`{}`),
+			expectedErr: "incorrect First Party Data for bidder BidderA: Site object cannot set empty page if req.site.id is empty",
 		},
-		Ext: []byte(`{"bidRequestSiteExt": 1234}`),
-	}
-	fpdConfigSite := make(map[string]json.RawMessage, 0)
-	fpdConfigSite["id"] = []byte(`"fpdConfigSiteId"`)
-	fpdConfigSite[keywordsKey] = []byte(`"fpdConfigSiteKeywords"`)
-	fpdConfigSite[pageKey] = []byte(`"fpdConfigSitePage"`)
-	fpdConfigSite[nameKey] = []byte(`"fpdConfigSiteName"`)
-	fpdConfigSite[domainKey] = []byte(`"fpdConfigSiteDomain"`)
-	fpdConfigSite[catKey] = []byte(`["cars1", "auto1"]`)
-	fpdConfigSite[sectionCatKey] = []byte(`["cars2", "auto2"]`)
-	fpdConfigSite[pageCatKey] = []byte(`["cars3", "auto3"]`)
-	fpdConfigSite[searchKey] = []byte(`"fpdConfigSiteSearch"`)
-	fpdConfigSite[refKey] = []byte(`"fpdConfigSiteRef"`)
-	fpdConfigSite[dataKey] = []byte(`[{"id":"SiteDataId1", "name":"SiteDataName1"}, {"id":"SiteDataId2", "name":"SiteDataName2"}]`)
-	fpdConfigSite["ext"] = []byte(`{"data":{"fpdConfigSiteExt": 123}}`)
-
-	resultSite, err := mergeSites(originalSite, fpdConfigSite, "appnexus")
-	assert.NoError(t, err, "No error should be returned")
-
-	expectedSiteExt := `{"bidRequestSiteExt":1234,
-						 "data":{
-							"data":[
-								{"id":"SiteDataId1","name":"SiteDataName1"},
-								{"id":"SiteDataId2","name":"SiteDataName2"}],
-							"fpdConfigSiteExt":123,
-							"id":"fpdConfigSiteId"}
-						 }`
-	assert.JSONEq(t, expectedSiteExt, string(resultSite.Ext), "Result user.Ext is incorrect")
-	resultSite.Ext = nil
-
-	expectedSite := openrtb2.Site{
-		ID:         "bidRequestSiteId",
-		Keywords:   "fpdConfigSiteKeywords",
-		Page:       "fpdConfigSitePage",
-		Name:       "fpdConfigSiteName",
-		Domain:     "fpdConfigSiteDomain",
-		Cat:        []string{"cars1", "auto1"},
-		SectionCat: []string{"cars2", "auto2"},
-		PageCat:    []string{"cars3", "auto3"},
-		Search:     "fpdConfigSiteSearch",
-		Ref:        "fpdConfigSiteRef",
-		Content: &openrtb2.Content{
-			Title: "bidRequestSiteContentTitle",
-			Data: []openrtb2.Data{
-				{ID: "openRtbGlobalFPDSiteDataId1", Name: "openRtbGlobalFPDSiteDataName1"},
-				{ID: "openRtbGlobalFPDSiteDataId2", Name: "openRtbGlobalFPDSiteDataName2"},
-			},
+		{
+			name:         "toplevel",
+			givenSite:    openrtb2.Site{ID: "1"},
+			givenFPD:     []byte(`{"id":"2"}`),
+			expectedSite: openrtb2.Site{ID: "2"},
 		},
-		Ext: nil,
-	}
-	assert.Equal(t, expectedSite, resultSite, "Result user is incorrect")
-}
-
-func TestResolveApp(t *testing.T) {
-
-	fpdConfigApp := make(map[string]json.RawMessage, 0)
-	fpdConfigApp["id"] = []byte(`"fpdConfigAppId"`)
-	fpdConfigApp[keywordsKey] = []byte(`"fpdConfigAppKeywords"`)
-	fpdConfigApp[nameKey] = []byte(`"fpdConfigAppName"`)
-	fpdConfigApp[bundleKey] = []byte(`"fpdConfigAppBundle"`)
-	fpdConfigApp[dataKey] = []byte(`[{"id":"AppDataId1", "name":"AppDataName1"}, {"id":"AppDataId2", "name":"AppDataName2"}]`)
-	fpdConfigApp["ext"] = []byte(`{"data":{"fpdConfigAppExt": 123}}`)
-
-	bidRequestApp := &openrtb2.App{
-		ID:       "bidRequestAppId",
-		Keywords: "bidRequestAppKeywords",
-		Name:     "bidRequestAppName",
-		Bundle:   "bidRequestAppBundle",
-		Content: &openrtb2.Content{
-			ID:      "bidRequestAppContentId",
-			Episode: 4,
-			Data: []openrtb2.Data{
-				{ID: "bidRequestAppContentDataId1", Name: "bidRequestAppContentDataName1"},
-				{ID: "bidRequestAppContentDataId2", Name: "bidRequestAppContentDataName2"},
-			},
+		{
+			name:         "toplevel-ext",
+			givenSite:    openrtb2.Site{Page: "test.com/page", Ext: []byte(`{"a":1,"b":2}`)},
+			givenFPD:     []byte(`{"ext":{"b":100,"c":3}}`),
+			expectedSite: openrtb2.Site{Page: "test.com/page", Ext: []byte(`{"a":1,"b":100,"c":3}`)},
+		},
+		{
+			name:        "toplevel-ext-err",
+			givenSite:   openrtb2.Site{ID: "1", Ext: []byte(`malformed`)},
+			givenFPD:    []byte(`{"id":"2"}`),
+			expectedErr: "invalid request ext",
+		},
+		{
+			name:         "nested-publisher",
+			givenSite:    openrtb2.Site{Page: "test.com/page", Publisher: &openrtb2.Publisher{Name: "pub1"}},
+			givenFPD:     []byte(`{"publisher":{"name": "pub2"}}`),
+			expectedSite: openrtb2.Site{Page: "test.com/page", Publisher: &openrtb2.Publisher{Name: "pub2"}},
+		},
+		{
+			name:         "nested-content",
+			givenSite:    openrtb2.Site{Page: "test.com/page", Content: &openrtb2.Content{Title: "content1"}},
+			givenFPD:     []byte(`{"content":{"title": "content2"}}`),
+			expectedSite: openrtb2.Site{Page: "test.com/page", Content: &openrtb2.Content{Title: "content2"}},
+		},
+		{
+			name:         "nested-content-producer",
+			givenSite:    openrtb2.Site{ID: "1", Content: &openrtb2.Content{Title: "content1", Producer: &openrtb2.Producer{Name: "producer1"}}},
+			givenFPD:     []byte(`{"content":{"title": "content2", "producer":{"name":"producer2"}}}`),
+			expectedSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Title: "content2", Producer: &openrtb2.Producer{Name: "producer2"}}},
+		},
+		{
+			name:         "nested-content-network",
+			givenSite:    openrtb2.Site{ID: "1", Content: &openrtb2.Content{Title: "content1", Network: &openrtb2.Network{Name: "network1"}}},
+			givenFPD:     []byte(`{"content":{"title": "content2", "network":{"name":"network2"}}}`),
+			expectedSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Title: "content2", Network: &openrtb2.Network{Name: "network2"}}},
+		},
+		{
+			name:         "nested-content-channel",
+			givenSite:    openrtb2.Site{ID: "1", Content: &openrtb2.Content{Title: "content1", Channel: &openrtb2.Channel{Name: "channel1"}}},
+			givenFPD:     []byte(`{"content":{"title": "content2", "channel":{"name":"channel2"}}}`),
+			expectedSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Title: "content2", Channel: &openrtb2.Channel{Name: "channel2"}}},
+		},
+		{
+			name:         "nested-publisher-ext",
+			givenSite:    openrtb2.Site{ID: "1", Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":1,"b":2}`)}},
+			givenFPD:     []byte(`{"publisher":{"ext":{"b":100,"c":3}}}`),
+			expectedSite: openrtb2.Site{ID: "1", Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":1,"b":100,"c":3}`)}},
+		},
+		{
+			name:         "nested-content-ext",
+			givenSite:    openrtb2.Site{ID: "1", Content: &openrtb2.Content{Ext: []byte(`{"a":1,"b":2}`)}},
+			givenFPD:     []byte(`{"content":{"ext":{"b":100,"c":3}}}`),
+			expectedSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Ext: []byte(`{"a":1,"b":100,"c":3}`)}},
+		},
+		{
+			name:         "nested-content-producer-ext",
+			givenSite:    openrtb2.Site{ID: "1", Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":1,"b":2}`)}}},
+			givenFPD:     []byte(`{"content":{"producer":{"ext":{"b":100,"c":3}}}}`),
+			expectedSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}},
+		},
+		{
+			name:         "nested-content-network-ext",
+			givenSite:    openrtb2.Site{ID: "1", Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":1,"b":2}`)}}},
+			givenFPD:     []byte(`{"content":{"network":{"ext":{"b":100,"c":3}}}}`),
+			expectedSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}},
+		},
+		{
+			name:         "nested-content-channel-ext",
+			givenSite:    openrtb2.Site{ID: "1", Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":1,"b":2}`)}}},
+			givenFPD:     []byte(`{"content":{"channel":{"ext":{"b":100,"c":3}}}}`),
+			expectedSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}},
+		},
+		{
+			name:         "toplevel-ext-and-nested-publisher-ext",
+			givenSite:    openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":2}`), Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":10,"b":20}`)}},
+			givenFPD:     []byte(`{"ext":{"b":100,"c":3}, "publisher":{"ext":{"b":100,"c":3}}}`),
+			expectedSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":100,"c":3}`), Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":10,"b":100,"c":3}`)}},
+		},
+		{
+			name:         "toplevel-ext-and-nested-content-ext",
+			givenSite:    openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Ext: []byte(`{"a":10,"b":20}`)}},
+			givenFPD:     []byte(`{"ext":{"b":100,"c":3}, "content":{"ext":{"b":100,"c":3}}}`),
+			expectedSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Ext: []byte(`{"a":10,"b":100,"c":3}`)}},
+		},
+		{
+			name:         "toplevel-ext-and-nested-content-producer-ext",
+			givenSite:    openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":10,"b":20}`)}}},
+			givenFPD:     []byte(`{"ext":{"b":100,"c":3}, "content":{"producer": {"ext":{"b":100,"c":3}}}}`),
+			expectedSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}},
+		},
+		{
+			name:         "toplevel-ext-and-nested-content-network-ext",
+			givenSite:    openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":10,"b":20}`)}}},
+			givenFPD:     []byte(`{"ext":{"b":100,"c":3}, "content":{"network": {"ext":{"b":100,"c":3}}}}`),
+			expectedSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}},
+		},
+		{
+			name:         "toplevel-ext-and-nested-content-channel-ext",
+			givenSite:    openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":10,"b":20}`)}}},
+			givenFPD:     []byte(`{"ext":{"b":100,"c":3}, "content":{"channel": {"ext":{"b":100,"c":3}}}}`),
+			expectedSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}},
+		},
+		{
+			name:        "nested-publisher-ext-err",
+			givenSite:   openrtb2.Site{ID: "1", Publisher: &openrtb2.Publisher{Ext: []byte(`malformed`)}},
+			givenFPD:    []byte(`{"publisher":{"ext":{"b":100,"c":3}}}`),
+			expectedErr: "invalid request ext",
+		},
+		{
+			name:        "nested-content-ext-err",
+			givenSite:   openrtb2.Site{ID: "1", Content: &openrtb2.Content{Ext: []byte(`malformed`)}},
+			givenFPD:    []byte(`{"content":{"ext":{"b":100,"c":3}}}`),
+			expectedErr: "invalid request ext",
+		},
+		{
+			name:        "nested-content-producer-ext-err",
+			givenSite:   openrtb2.Site{ID: "1", Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`malformed`)}}},
+			givenFPD:    []byte(`{"content":{"producer": {"ext":{"b":100,"c":3}}}}`),
+			expectedErr: "invalid request ext",
+		},
+		{
+			name:        "nested-content-network-ext-err",
+			givenSite:   openrtb2.Site{ID: "1", Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`malformed`)}}},
+			givenFPD:    []byte(`{"content":{"network": {"ext":{"b":100,"c":3}}}}`),
+			expectedErr: "invalid request ext",
+		},
+		{
+			name:        "nested-content-channel-ext-err",
+			givenSite:   openrtb2.Site{ID: "1", Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`malformed`)}}},
+			givenFPD:    []byte(`{"content":{"channelx": {"ext":{"b":100,"c":3}}}}`),
+			expectedErr: "invalid request ext",
+		},
+		{
+			name:        "fpd-err",
+			givenSite:   openrtb2.Site{ID: "1", Ext: []byte(`{"a":1}`)},
+			givenFPD:    []byte(`malformed`),
+			expectedErr: "invalid character 'm' looking for beginning of value",
 		},
 	}
 
-	globalFPD := make(map[string][]byte, 0)
-	globalFPD[appKey] = []byte(`{"globalFPDAppData": "globalFPDAppDataValue"}`)
-
-	openRtbGlobalFPD := make(map[string][]openrtb2.Data, 0)
-	openRtbGlobalFPD[appContentDataKey] = []openrtb2.Data{
-		{ID: "openRtbGlobalFPDAppContentDataId1", Name: "openRtbGlobalFPDAppContentDataName1"},
-		{ID: "openRtbGlobalFPDAppContentDataId2", Name: "openRtbGlobalFPDAppContentDataName2"},
-	}
+	for _, test := range testCases {
+		t.Run(test.name, func(t *testing.T) {
+			err := mergeSite(&test.givenSite, test.givenFPD, "BidderA")
 
-	expectedApp := &openrtb2.App{
-		ID:       "bidRequestAppId",
-		Keywords: "fpdConfigAppKeywords",
-		Name:     "bidRequestAppName",
-		Bundle:   "bidRequestAppBundle",
-		Content: &openrtb2.Content{
-			ID:      "bidRequestAppContentId",
-			Episode: 4,
-			Data: []openrtb2.Data{
-				{ID: "openRtbGlobalFPDAppContentDataId1", Name: "openRtbGlobalFPDAppContentDataName1"},
-				{ID: "openRtbGlobalFPDAppContentDataId2", Name: "openRtbGlobalFPDAppContentDataName2"},
-			},
-		},
+			if test.expectedErr == "" {
+				assert.NoError(t, err, "unexpected error returned")
+				assert.Equal(t, test.expectedSite, test.givenSite, " result Site is incorrect")
+			} else {
+				assert.EqualError(t, err, test.expectedErr, "expected error incorrect")
+			}
+		})
 	}
+}
 
+// TestMergeObjectStructure detects when new nested objects are added to First Party Data supported
+// fields, as these will invalidate the mergeSite, mergeApp, and mergeUser methods. If this test fails,
+// fix the merge methods to add support and update this test to set a new baseline.
+func TestMergeObjectStructure(t *testing.T) {
 	testCases := []struct {
-		description      string
-		bidRequestAppExt []byte
-		expectedAppExt   string
-		appContentNil    bool
+		name         string
+		kind         any
+		knownStructs []string
 	}{
 		{
-			description:      "bid request app.ext is nil",
-			bidRequestAppExt: nil,
-			expectedAppExt: `{"data":{
-									"data":[
-										{"id":"AppDataId1","name":"AppDataName1"},
-										{"id":"AppDataId2","name":"AppDataName2"}
-									],
-									"fpdConfigAppExt":123,
-									"globalFPDAppData":"globalFPDAppDataValue",
-									"id":"fpdConfigAppId"
-									}
-							}`,
-			appContentNil: false,
-		},
-		{
-			description:      "bid request app.ext is not nil",
-			bidRequestAppExt: []byte(`{"bidRequestAppExt": 1234}`),
-			expectedAppExt: `{"data":{
-									"data":[
-										{"id":"AppDataId1","name":"AppDataName1"},
-										{"id":"AppDataId2","name":"AppDataName2"}
-									],
-									"fpdConfigAppExt":123,
-									"globalFPDAppData":"globalFPDAppDataValue",
-									"id":"fpdConfigAppId"
-									},
-								"bidRequestAppExt":1234
-							}`,
-			appContentNil: false,
-		},
-		{
-			description:      "bid request app.content.data is nil ",
-			bidRequestAppExt: []byte(`{"bidRequestAppExt": 1234}`),
-			expectedAppExt: `{"data":{
-									"data":[
-										{"id":"AppDataId1","name":"AppDataName1"},
-										{"id":"AppDataId2","name":"AppDataName2"}
-									],
-									"fpdConfigAppExt":123,
-									"globalFPDAppData":"globalFPDAppDataValue",
-									"id":"fpdConfigAppId"
-									},
-								"bidRequestAppExt":1234
-							}`,
-			appContentNil: true,
+			name: "Site",
+			kind: openrtb2.Site{},
+			knownStructs: []string{
+				"Publisher",
+				"Content",
+				"Content.Producer",
+				"Content.Network",
+				"Content.Channel",
+			},
+		},
+		{
+			name: "App",
+			kind: openrtb2.App{},
+			knownStructs: []string{
+				"Publisher",
+				"Content",
+				"Content.Producer",
+				"Content.Network",
+				"Content.Channel",
+			},
+		},
+		{
+			name: "User",
+			kind: openrtb2.User{},
+			knownStructs: []string{
+				"Geo",
+			},
 		},
 	}
 
 	for _, test := range testCases {
-		if test.appContentNil {
-			bidRequestApp.Content = nil
-			expectedApp.Content = &openrtb2.Content{Data: []openrtb2.Data{
-				{ID: "openRtbGlobalFPDAppContentDataId1", Name: "openRtbGlobalFPDAppContentDataName1"},
-				{ID: "openRtbGlobalFPDAppContentDataId2", Name: "openRtbGlobalFPDAppContentDataName2"},
-			}}
-		}
-
-		bidRequestApp.Ext = test.bidRequestAppExt
-
-		fpdConfigApp := make(map[string]json.RawMessage, 0)
-		fpdConfigApp["id"] = []byte(`"fpdConfigAppId"`)
-		fpdConfigApp[keywordsKey] = []byte(`"fpdConfigAppKeywords"`)
-		fpdConfigApp[dataKey] = []byte(`[{"id":"AppDataId1", "name":"AppDataName1"}, {"id":"AppDataId2", "name":"AppDataName2"}]`)
-		fpdConfigApp["ext"] = []byte(`{"data":{"fpdConfigAppExt": 123}}`)
-		fpdConfig := &openrtb_ext.ORTB2{App: fpdConfigApp}
-
-		resultApp, err := resolveApp(fpdConfig, bidRequestApp, globalFPD, openRtbGlobalFPD, "appnexus")
-		assert.NoError(t, err, "No error should be returned")
+		t.Run(test.name, func(t *testing.T) {
+			nestedStructs := []string{}
+
+			var discover func(parent string, t reflect.Type)
+			discover = func(parent string, t reflect.Type) {
+				fields := reflect.VisibleFields(t)
+				for _, field := range fields {
+					if field.Type.Kind() == reflect.Pointer && field.Type.Elem().Kind() == reflect.Struct {
+						nestedStructs = append(nestedStructs, parent+field.Name)
+						discover(parent+field.Name+".", field.Type.Elem())
+					}
+				}
+			}
+			discover("", reflect.TypeOf(test.kind))
 
-		assert.JSONEq(t, test.expectedAppExt, string(resultApp.Ext), "Result app.Ext is incorrect")
-		resultApp.Ext = nil
-		assert.Equal(t, expectedApp, resultApp, "Result app is incorrect")
+			assert.ElementsMatch(t, test.knownStructs, nestedStructs)
+		})
 	}
-
 }
 
-func TestResolveAppNilValues(t *testing.T) {
-	resultApp, err := resolveApp(nil, nil, nil, nil, "appnexus")
-	assert.NoError(t, err, "No error should be returned")
-	assert.Nil(t, resultApp, "Result app should be nil")
-}
+// user memory protect test
+func TestMergeUserMemoryProtection(t *testing.T) {
+	inputGeo := &openrtb2.Geo{
+		Ext: json.RawMessage(`{"a":1,"b":2}`),
+	}
+	input := openrtb2.User{
+		ID:  "1",
+		Geo: inputGeo,
+	}
 
-func TestResolveAppBadInput(t *testing.T) {
-	fpdConfigApp := make(map[string]json.RawMessage, 0)
-	fpdConfigApp["id"] = []byte(`"fpdConfigAppId"`)
-	fpdConfig := &openrtb_ext.ORTB2{App: fpdConfigApp}
+	err := mergeUser(&input, userFPD)
+	assert.NoError(t, err)
 
-	resultApp, err := resolveApp(fpdConfig, nil, nil, nil, "appnexus")
-	assert.Error(t, err, "Error should be returned")
-	assert.Equal(t, "incorrect First Party Data for bidder appnexus: App object is not defined in request, but defined in FPD config", err.Error(), "Incorrect error message")
-	assert.Nil(t, resultApp, "Result app should be nil")
+	// Input user object is expected to be a copy. Changes are ok.
+	assert.Equal(t, "2", input.ID, "user-id-copied")
+
+	// Nested objects must be copied before changes.
+	assert.JSONEq(t, `{"a":1,"b":2}`, string(inputGeo.Ext), "geo-input")
+	assert.JSONEq(t, `{"a":1,"b":100,"c":3}`, string(input.Geo.Ext), "geo-copied")
 }
 
-func TestMergeApps(t *testing.T) {
-
-	originalApp := &openrtb2.App{
-		ID:         "bidRequestAppId",
-		Keywords:   "bidRequestAppKeywords",
-		Name:       "bidRequestAppName",
-		Domain:     "bidRequestAppDomain",
-		Bundle:     "bidRequestAppBundle",
-		StoreURL:   "bidRequestAppStoreUrl",
-		Ver:        "bidRequestAppVer",
-		Cat:        []string{"books1", "magazines1"},
-		SectionCat: []string{"books2", "magazines2"},
-		PageCat:    []string{"books3", "magazines3"},
-		Content: &openrtb2.Content{
-			Title: "bidRequestAppContentTitle",
-			Data: []openrtb2.Data{
-				{ID: "openRtbGlobalFPDAppDataId1", Name: "openRtbGlobalFPDAppDataName1"},
-				{ID: "openRtbGlobalFPDAppDataId2", Name: "openRtbGlobalFPDAppDataName2"},
-			},
-		},
-		Ext: []byte(`{"bidRequestAppExt": 1234}`),
+// app memory protect test
+func TestMergeAppMemoryProtection(t *testing.T) {
+	inputPublisher := &openrtb2.Publisher{
+		ID:  "InPubId",
+		Ext: json.RawMessage(`{"a": "inputPubExt", "b": 1}`),
 	}
-	fpdConfigApp := make(map[string]json.RawMessage, 0)
-	fpdConfigApp["id"] = []byte(`"fpdConfigAppId"`)
-	fpdConfigApp[keywordsKey] = []byte(`"fpdConfigAppKeywords"`)
-	fpdConfigApp[nameKey] = []byte(`"fpdConfigAppName"`)
-	fpdConfigApp[domainKey] = []byte(`"fpdConfigAppDomain"`)
-	fpdConfigApp[bundleKey] = []byte(`"fpdConfigAppBundle"`)
-	fpdConfigApp[storeUrlKey] = []byte(`"fpdConfigAppStoreUrl"`)
-	fpdConfigApp[verKey] = []byte(`"fpdConfigAppVer"`)
-	fpdConfigApp[catKey] = []byte(`["cars1", "auto1"]`)
-	fpdConfigApp[sectionCatKey] = []byte(`["cars2", "auto2"]`)
-	fpdConfigApp[pageCatKey] = []byte(`["cars3", "auto3"]`)
-	fpdConfigApp[dataKey] = []byte(`[{"id":"AppDataId1", "name":"AppDataName1"}, {"id":"AppDataId2", "name":"AppDataName2"}]`)
-	fpdConfigApp["ext"] = []byte(`{"data":{"fpdConfigAppExt": 123}}`)
-
-	resultApp, err := mergeApps(originalApp, fpdConfigApp)
-	assert.NoError(t, err, "No error should be returned")
-
-	expectedAppExt := `{"bidRequestAppExt":1234,
-						 "data":{
-							"data":[
-								{"id":"AppDataId1","name":"AppDataName1"},
-								{"id":"AppDataId2","name":"AppDataName2"}],
-							"fpdConfigAppExt":123,
-							"id":"fpdConfigAppId"}
-						 }`
-	assert.JSONEq(t, expectedAppExt, string(resultApp.Ext), "Result user.Ext is incorrect")
-	resultApp.Ext = nil
-
-	expectedApp := openrtb2.App{
-		ID:         "bidRequestAppId",
-		Keywords:   "fpdConfigAppKeywords",
-		Name:       "fpdConfigAppName",
-		Domain:     "fpdConfigAppDomain",
-		Bundle:     "fpdConfigAppBundle",
-		Ver:        "fpdConfigAppVer",
-		StoreURL:   "fpdConfigAppStoreUrl",
-		Cat:        []string{"cars1", "auto1"},
-		SectionCat: []string{"cars2", "auto2"},
-		PageCat:    []string{"cars3", "auto3"},
-		Content: &openrtb2.Content{
-			Title: "bidRequestAppContentTitle",
-			Data: []openrtb2.Data{
-				{ID: "openRtbGlobalFPDAppDataId1", Name: "openRtbGlobalFPDAppDataName1"},
-				{ID: "openRtbGlobalFPDAppDataId2", Name: "openRtbGlobalFPDAppDataName2"},
-			},
+	inputContent := &openrtb2.Content{
+		ID:  "InContentId",
+		Ext: json.RawMessage(`{"a": "inputContentExt", "b": 1}`),
+		Producer: &openrtb2.Producer{
+			ID:  "InProducerId",
+			Ext: json.RawMessage(`{"a": "inputProducerExt", "b": 1}`),
 		},
-		Ext: nil,
+		Network: &openrtb2.Network{
+			ID:  "InNetworkId",
+			Ext: json.RawMessage(`{"a": "inputNetworkExt", "b": 1}`),
+		},
+		Channel: &openrtb2.Channel{
+			ID:  "InChannelId",
+			Ext: json.RawMessage(`{"a": "inputChannelExt", "b": 1}`),
+		},
+	}
+	input := openrtb2.App{
+		ID:        "InAppID",
+		Publisher: inputPublisher,
+		Content:   inputContent,
+		Ext:       json.RawMessage(`{"a": "inputAppExt", "b": 1}`),
 	}
-	assert.Equal(t, expectedApp, resultApp, "Result user is incorrect")
+
+	err := mergeApp(&input, fpdWithPublisherAndContent)
+	assert.NoError(t, err)
+
+	// Input app object is expected to be a copy. Changes are ok.
+	assert.Equal(t, "FPDID", input.ID, "app-id-copied")
+	assert.JSONEq(t, `{"a": "FPDExt", "b": 2}`, string(input.Ext), "app-ext-copied")
+
+	// Nested objects must be copied before changes.
+	assert.Equal(t, "InPubId", inputPublisher.ID, "app-pub-id-input")
+	assert.Equal(t, "FPDPubId", input.Publisher.ID, "app-pub-id-copied")
+	assert.JSONEq(t, `{"a": "inputPubExt", "b": 1}`, string(inputPublisher.Ext), "app-pub-ext-input")
+	assert.JSONEq(t, `{"a": "FPDPubExt", "b": 2}`, string(input.Publisher.Ext), "app-pub-ext-copied")
+
+	assert.Equal(t, "InContentId", inputContent.ID, "app-content-id-input")
+	assert.Equal(t, "FPDContentId", input.Content.ID, "app-content-id-copied")
+	assert.JSONEq(t, `{"a": "inputContentExt", "b": 1}`, string(inputContent.Ext), "app-content-ext-input")
+	assert.JSONEq(t, `{"a": "FPDContentExt", "b": 2}`, string(input.Content.Ext), "app-content-ext-copied")
+
+	assert.Equal(t, "InProducerId", inputContent.Producer.ID, "app-content-producer-id-input")
+	assert.Equal(t, "FPDProducerId", input.Content.Producer.ID, "app-content-producer-id-copied")
+	assert.JSONEq(t, `{"a": "inputProducerExt", "b": 1}`, string(inputContent.Producer.Ext), "app-content-producer-ext-input")
+	assert.JSONEq(t, `{"a": "FPDProducerExt", "b": 2}`, string(input.Content.Producer.Ext), "app-content-producer-ext-copied")
+
+	assert.Equal(t, "InNetworkId", inputContent.Network.ID, "app-content-network-id-input")
+	assert.Equal(t, "FPDNetworkId", input.Content.Network.ID, "app-content-network-id-copied")
+	assert.JSONEq(t, `{"a": "inputNetworkExt", "b": 1}`, string(inputContent.Network.Ext), "app-content-network-ext-input")
+	assert.JSONEq(t, `{"a": "FPDNetworkExt", "b": 2}`, string(input.Content.Network.Ext), "app-content-network-ext-copied")
+
+	assert.Equal(t, "InChannelId", inputContent.Channel.ID, "app-content-channel-id-input")
+	assert.Equal(t, "FPDChannelId", input.Content.Channel.ID, "app-content-channel-id-copied")
+	assert.JSONEq(t, `{"a": "inputChannelExt", "b": 1}`, string(inputContent.Channel.Ext), "app-content-channel-ext-input")
+	assert.JSONEq(t, `{"a": "FPDChannelExt", "b": 2}`, string(input.Content.Channel.Ext), "app-content-channel-ext-copied")
 }
 
-func TestBuildExtData(t *testing.T) {
-	testCases := []struct {
-		description string
-		input       []byte
-		expectedRes string
-	}{
-		{
-			description: "Input object with int value",
-			input:       []byte(`{"someData": 123}`),
-			expectedRes: `{"data": {"someData": 123}}`,
-		},
-		{
-			description: "Input object with bool value",
-			input:       []byte(`{"someData": true}`),
-			expectedRes: `{"data": {"someData": true}}`,
+// site memory protect test
+func TestMergeSiteMemoryProtection(t *testing.T) {
+	inputPublisher := &openrtb2.Publisher{
+		ID:  "InPubId",
+		Ext: json.RawMessage(`{"a": "inputPubExt", "b": 1}`),
+	}
+	inputContent := &openrtb2.Content{
+		ID:  "InContentId",
+		Ext: json.RawMessage(`{"a": "inputContentExt", "b": 1}`),
+		Producer: &openrtb2.Producer{
+			ID:  "InProducerId",
+			Ext: json.RawMessage(`{"a": "inputProducerExt", "b": 1}`),
 		},
-		{
-			description: "Input object with string value",
-			input:       []byte(`{"someData": "true"}`),
-			expectedRes: `{"data": {"someData": "true"}}`,
+		Network: &openrtb2.Network{
+			ID:  "InNetworkId",
+			Ext: json.RawMessage(`{"a": "inputNetworkExt", "b": 1}`),
 		},
-		{
-			description: "No input object",
-			input:       []byte(`{}`),
-			expectedRes: `{"data": {}}`,
-		},
-		{
-			description: "Input object with object value",
-			input:       []byte(`{"someData": {"moreFpdData": "fpddata"}}`),
-			expectedRes: `{"data": {"someData": {"moreFpdData": "fpddata"}}}`,
+		Channel: &openrtb2.Channel{
+			ID:  "InChannelId",
+			Ext: json.RawMessage(`{"a": "inputChannelExt", "b": 1}`),
 		},
 	}
-
-	for _, test := range testCases {
-		actualRes := buildExtData(test.input)
-		assert.JSONEq(t, test.expectedRes, string(actualRes), "Incorrect result data")
+	input := openrtb2.Site{
+		ID:        "InSiteID",
+		Publisher: inputPublisher,
+		Content:   inputContent,
+		Ext:       json.RawMessage(`{"a": "inputSiteExt", "b": 1}`),
 	}
+
+	err := mergeSite(&input, fpdWithPublisherAndContent, "BidderA")
+	assert.NoError(t, err)
+
+	// Input app object is expected to be a copy. Changes are ok.
+	assert.Equal(t, "FPDID", input.ID, "site-id-copied")
+	assert.JSONEq(t, `{"a": "FPDExt", "b": 2}`, string(input.Ext), "site-ext-copied")
+
+	// Nested objects must be copied before changes.
+	assert.Equal(t, "InPubId", inputPublisher.ID, "site-pub-id-input")
+	assert.Equal(t, "FPDPubId", input.Publisher.ID, "site-pub-id-copied")
+	assert.JSONEq(t, `{"a": "inputPubExt", "b": 1}`, string(inputPublisher.Ext), "site-pub-ext-input")
+	assert.JSONEq(t, `{"a": "FPDPubExt", "b": 2}`, string(input.Publisher.Ext), "site-pub-ext-copied")
+
+	assert.Equal(t, "InContentId", inputContent.ID, "site-content-id-input")
+	assert.Equal(t, "FPDContentId", input.Content.ID, "site-content-id-copied")
+	assert.JSONEq(t, `{"a": "inputContentExt", "b": 1}`, string(inputContent.Ext), "site-content-ext-input")
+	assert.JSONEq(t, `{"a": "FPDContentExt", "b": 2}`, string(input.Content.Ext), "site-content-ext-copied")
+
+	assert.Equal(t, "InProducerId", inputContent.Producer.ID, "site-content-producer-id-input")
+	assert.Equal(t, "FPDProducerId", input.Content.Producer.ID, "site-content-producer-id-copied")
+	assert.JSONEq(t, `{"a": "inputProducerExt", "b": 1}`, string(inputContent.Producer.Ext), "site-content-producer-ext-input")
+	assert.JSONEq(t, `{"a": "FPDProducerExt", "b": 2}`, string(input.Content.Producer.Ext), "site-content-producer-ext-copied")
+
+	assert.Equal(t, "InNetworkId", inputContent.Network.ID, "site-content-network-id-input")
+	assert.Equal(t, "FPDNetworkId", input.Content.Network.ID, "site-content-network-id-copied")
+	assert.JSONEq(t, `{"a": "inputNetworkExt", "b": 1}`, string(inputContent.Network.Ext), "site-content-network-ext-input")
+	assert.JSONEq(t, `{"a": "FPDNetworkExt", "b": 2}`, string(input.Content.Network.Ext), "site-content-network-ext-copied")
+
+	assert.Equal(t, "InChannelId", inputContent.Channel.ID, "site-content-channel-id-input")
+	assert.Equal(t, "FPDChannelId", input.Content.Channel.ID, "site-content-channel-id-copied")
+	assert.JSONEq(t, `{"a": "inputChannelExt", "b": 1}`, string(inputContent.Channel.Ext), "site-content-channel-ext-input")
+	assert.JSONEq(t, `{"a": "FPDChannelExt", "b": 2}`, string(input.Content.Channel.Ext), "site-content-channel-ext-copied")
+}
+
+var (
+	userFPD = []byte(`
+{
+  "id": "2",
+  "geo": {
+    "ext": {
+      "b": 100,
+      "c": 3
+    }
+  }
 }
+`)
+
+	fpdWithPublisherAndContent = []byte(`
+{
+  "id": "FPDID",
+  "ext": {"a": "FPDExt", "b": 2},
+  "publisher": {
+    "id": "FPDPubId",
+    "ext": {"a": "FPDPubExt", "b": 2}
+  },
+  "content": {
+    "id": "FPDContentId",
+    "ext": {"a": "FPDContentExt", "b": 2},
+    "producer": {
+      "id": "FPDProducerId",
+      "ext": {"a": "FPDProducerExt", "b": 2}
+    },
+    "network": {
+      "id": "FPDNetworkId",
+      "ext": {"a": "FPDNetworkExt", "b": 2}
+    },
+    "channel": {
+      "id": "FPDChannelId",
+      "ext": {"a": "FPDChannelExt", "b": 2}
+    }
+  }
+}
+`)
+
+	user = []byte(`
+{
+  "id": "2",
+  "yob": 2000,
+  "geo": {
+    "city": "LA",
+    "ext": {
+      "b": 100,
+      "c": 3
+    }
+  }
+}
+`)
+)
diff --git a/firstpartydata/tests/extractbidderconfigfpd/bidder-config-config-null.json b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-config-null.json
index 6d2d54f6..751a0bec 100644
--- a/firstpartydata/tests/extractbidderconfigfpd/bidder-config-config-null.json
+++ b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-config-null.json
@@ -11,10 +11,8 @@
       }
     ]
   },
-  "outputRequestData": {
-  },
+  "outputRequestData": {},
   "bidderConfigFPD": {
     "appnexus": {}
   }
-}
-
+}
\ No newline at end of file
diff --git a/firstpartydata/tests/extractbidderconfigfpd/bidder-config-config-ortb-null.json b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-config-ortb-null.json
index 3bafae6d..d7a6e764 100644
--- a/firstpartydata/tests/extractbidderconfigfpd/bidder-config-config-ortb-null.json
+++ b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-config-ortb-null.json
@@ -13,10 +13,8 @@
       }
     ]
   },
-  "outputRequestData": {
-  },
+  "outputRequestData": {},
   "bidderConfigFPD": {
-    "appnexus": {
-    }
+    "appnexus": {}
   }
-}
+}
\ No newline at end of file
diff --git a/firstpartydata/tests/extractbidderconfigfpd/bidder-config-duplicated.json b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-duplicated.json
index 356b84aa..bf9d1843 100644
--- a/firstpartydata/tests/extractbidderconfigfpd/bidder-config-duplicated.json
+++ b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-duplicated.json
@@ -56,8 +56,8 @@
   "outputRequestData": {},
   "bidderConfigFPD": {},
   "validationErrors": [
-    {"Message": "multiple First Party Data bidder configs provided for bidder: appnexus"}
+    {
+      "Message": "multiple First Party Data bidder configs provided for bidder: appnexus"
+    }
   ]
-}
-
-
+}
\ No newline at end of file
diff --git a/firstpartydata/tests/extractbidderconfigfpd/bidder-config-empty.json b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-empty.json
index d127e5df..5caba335 100644
--- a/firstpartydata/tests/extractbidderconfigfpd/bidder-config-empty.json
+++ b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-empty.json
@@ -6,6 +6,4 @@
   },
   "outputRequestData": {},
   "bidderConfigFPD": {}
-}
-
-
+}
\ No newline at end of file
diff --git a/firstpartydata/tests/extractbidderconfigfpd/bidder-config-multiple-bidder.json b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-multiple-bidder.json
index 48ea27a7..c54daf0c 100644
--- a/firstpartydata/tests/extractbidderconfigfpd/bidder-config-multiple-bidder.json
+++ b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-multiple-bidder.json
@@ -52,8 +52,7 @@
       }
     ]
   },
-  "outputRequestData": {
-  },
+  "outputRequestData": {},
   "bidderConfigFPD": {
     "appnexus": {
       "site": {
@@ -106,4 +105,4 @@
       }
     }
   }
-}
+}
\ No newline at end of file
diff --git a/firstpartydata/tests/extractbidderconfigfpd/bidder-config-not-specified.json b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-not-specified.json
index d3dfc5b0..e6e2c459 100644
--- a/firstpartydata/tests/extractbidderconfigfpd/bidder-config-not-specified.json
+++ b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-not-specified.json
@@ -3,5 +3,4 @@
   "inputRequestData": {},
   "outputRequestData": {},
   "bidderConfigFPD": {}
-}
-
+}
\ No newline at end of file
diff --git a/firstpartydata/tests/extractbidderconfigfpd/bidder-config-null.json b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-null.json
index 1063f7ec..697bc897 100644
--- a/firstpartydata/tests/extractbidderconfigfpd/bidder-config-null.json
+++ b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-null.json
@@ -6,6 +6,4 @@
   },
   "outputRequestData": {},
   "bidderConfigFPD": {}
-}
-
-
+}
\ No newline at end of file
diff --git a/firstpartydata/tests/extractbidderconfigfpd/bidder-list-specified-only.json b/firstpartydata/tests/extractbidderconfigfpd/bidder-list-specified-only.json
index 6c1938bc..ae5bc578 100644
--- a/firstpartydata/tests/extractbidderconfigfpd/bidder-list-specified-only.json
+++ b/firstpartydata/tests/extractbidderconfigfpd/bidder-list-specified-only.json
@@ -11,5 +11,4 @@
   },
   "outputRequestData": {},
   "bidderConfigFPD": {}
-}
-
+}
\ No newline at end of file
diff --git a/firstpartydata/tests/extractfpdforbidders/global-fpd-defined-app.json b/firstpartydata/tests/extractfpdforbidders/global-fpd-defined-app.json
index 4f312b3d..c8649d40 100644
--- a/firstpartydata/tests/extractfpdforbidders/global-fpd-defined-app.json
+++ b/firstpartydata/tests/extractfpdforbidders/global-fpd-defined-app.json
@@ -55,7 +55,7 @@
   "biddersFPDResolved": {
     "appnexus": {
       "app": {
-        "id": "reqAppId",
+        "id": "apnAppId",
         "name": "fpdAppName",
         "bundle": "fpdAppBundle",
         "domain": "fpdAppDomain",
@@ -67,11 +67,6 @@
         "ver": "1.2",
         "publisher": {
           "id": "1"
-        },
-        "ext": {
-          "data": {
-            "id": "apnAppId"
-          }
         }
       }
     }
diff --git a/firstpartydata/tests/extractfpdforbidders/global-fpd-defined-site.json b/firstpartydata/tests/extractfpdforbidders/global-fpd-defined-site.json
index 5fc7c0d7..7916c3f3 100644
--- a/firstpartydata/tests/extractfpdforbidders/global-fpd-defined-site.json
+++ b/firstpartydata/tests/extractfpdforbidders/global-fpd-defined-site.json
@@ -53,7 +53,7 @@
   "biddersFPDResolved": {
     "appnexus": {
       "site": {
-        "id": "reqSiteId",
+        "id": "apnSiteId",
         "keywords": "apnKeywords",
         "sectioncat": ["books"],
         "pagecat": ["magazines"],
@@ -61,11 +61,6 @@
         "page": "http://www.foobar.com/testurl.html",
         "publisher": {
           "id": "1"
-        },
-        "ext": {
-          "data": {
-            "id": "apnSiteId"
-          }
         }
       }
     }
diff --git a/firstpartydata/tests/extractfpdforbidders/req-empty-user-fpd-not-empty-user.json b/firstpartydata/tests/extractfpdforbidders/req-empty-user-fpd-not-empty-user.json
index 7ba65d1b..1469af16 100644
--- a/firstpartydata/tests/extractfpdforbidders/req-empty-user-fpd-not-empty-user.json
+++ b/firstpartydata/tests/extractfpdforbidders/req-empty-user-fpd-not-empty-user.json
@@ -50,6 +50,7 @@
   "biddersFPDResolved": {
     "appnexus": {
       "user": {
+        "id": "appnexusFpdUser",
         "yob": 2011,
         "gender": "F",
         "keywords": "fpd keywords",
@@ -65,12 +66,7 @@
         ],
         "ext": {
           "data": {
-            "ext": {
-              "data": {
-                "userdata": "appnexusFpdUserExtData"
-              }
-            },
-            "id": "appnexusFpdUser"
+            "userdata": "appnexusFpdUserExtData"
           }
         }
       }
diff --git a/firstpartydata/tests/extractfpdforbidders/two-bidders-correct-fpd.json b/firstpartydata/tests/extractfpdforbidders/two-bidders-correct-fpd.json
index 62b2a44e..26bb054f 100644
--- a/firstpartydata/tests/extractfpdforbidders/two-bidders-correct-fpd.json
+++ b/firstpartydata/tests/extractfpdforbidders/two-bidders-correct-fpd.json
@@ -67,24 +67,14 @@
   "biddersFPDResolved": {
     "appnexus": {
       "site": {
-        "id": "reqSiteId",
+        "id": "apnSiteId",
         "page": "http://www.foobar.com/1234.html",
         "publisher": {
           "id": "1"
-        },
-        "ext": {
-          "data": {
-            "id": "apnSiteId"
-          }
         }
       },
       "user": {
-        "id": "reqUserId",
-        "ext": {
-          "data": {
-            "id": "apnUserId"
-          }
-        }
+        "id": "apnUserId"
       }
     },
     "telaria": {
diff --git a/firstpartydata/tests/extractfpdforbidders/two-bidders-global-and-non-global-app-content-data.json b/firstpartydata/tests/extractfpdforbidders/two-bidders-global-and-non-global-app-content-data.json
index 2ae0456a..76254fb0 100644
--- a/firstpartydata/tests/extractfpdforbidders/two-bidders-global-and-non-global-app-content-data.json
+++ b/firstpartydata/tests/extractfpdforbidders/two-bidders-global-and-non-global-app-content-data.json
@@ -121,7 +121,7 @@
   "biddersFPDResolved": {
     "appnexus": {
       "app": {
-        "id": "reqAppId",
+        "id": "apnFpdAppId",
         "name": "apnFpdAppIdAppName",
         "cat": [
           "books",
@@ -144,24 +144,11 @@
               "name": "apnFpdAppIdContentDataName2"
             }
           ]
-        },
-        "ext": {
-          "data": {
-            "id": "apnFpdAppId",
-            "publisher": {
-              "id": "1"
-            }
-          }
         }
       },
       "user": {
-        "id": "reqUserId",
-        "keywords": "apnFpdUserKeyword",
-        "ext": {
-          "data": {
-            "id":"apnUserId"
-          }
-        }
+        "id": "apnUserId",
+        "keywords": "apnFpdUserKeyword"
       }
     }
   },
diff --git a/firstpartydata/tests/extractfpdforbidders/two-bidders-global-and-non-global-site-content-data.json b/firstpartydata/tests/extractfpdforbidders/two-bidders-global-and-non-global-site-content-data.json
index 5dfcee9c..e7c7514d 100644
--- a/firstpartydata/tests/extractfpdforbidders/two-bidders-global-and-non-global-site-content-data.json
+++ b/firstpartydata/tests/extractfpdforbidders/two-bidders-global-and-non-global-site-content-data.json
@@ -122,7 +122,7 @@
   "biddersFPDResolved": {
     "appnexus": {
       "site": {
-        "id": "reqSiteId",
+        "id": "apnFpdSiteId",
         "page": "http://www.foobar.com/4321.html",
         "name": "apnFpdSiteIdSiteName",
         "cat": [
@@ -146,24 +146,11 @@
               "name": "apnFpdSiteIdContentDataName2"
             }
           ]
-        },
-        "ext": {
-          "data": {
-            "id": "apnFpdSiteId",
-            "publisher": {
-              "id": "1"
-            }
-          }
         }
       },
       "user": {
-        "id": "reqUserId",
-        "keywords": "apnFpdUserKeyword",
-        "ext": {
-          "data": {
-            "id":"apnUserId"
-          }
-        }
+        "id": "apnUserId",
+        "keywords": "apnFpdUserKeyword"
       }
     }
   },
diff --git a/firstpartydata/tests/extractfpdforbidders/two-bidders-global-and-non-global-user.json b/firstpartydata/tests/extractfpdforbidders/two-bidders-global-and-non-global-user.json
index 88a79002..e5ea3994 100644
--- a/firstpartydata/tests/extractfpdforbidders/two-bidders-global-and-non-global-user.json
+++ b/firstpartydata/tests/extractfpdforbidders/two-bidders-global-and-non-global-user.json
@@ -146,7 +146,7 @@
   "biddersFPDResolved": {
     "appnexus": {
       "site": {
-        "id": "reqSiteId",
+        "id": "apnFpdSiteId",
         "page": "http://www.foobar.com/4321.html",
         "name": "apnFpdSiteIdSiteName",
         "cat": [
@@ -170,18 +170,10 @@
               "name": "apnFpdSiteIdContentDataName2"
             }
           ]
-        },
-        "ext": {
-          "data": {
-            "id": "apnFpdSiteId",
-            "publisher": {
-              "id": "1"
-            }
-          }
         }
       },
       "user": {
-        "id": "reqUserId",
+        "id": "apnUserId",
         "gender": "F",
         "yob": 2000,
         "keywords": "apnFpdUserKeyword",
@@ -194,12 +186,7 @@
             "id": "apnFpdUserContentDataId2",
             "name": "apnFpdUserContentDataName2"
           }
-        ],
-        "ext": {
-          "data": {
-            "id": "apnUserId"
-          }
-        }
+        ]
       }
     }
   },
diff --git a/firstpartydata/tests/extractfpdforbidders/two-bidders-global-without-config-and-global-app-content-data.json b/firstpartydata/tests/extractfpdforbidders/two-bidders-global-without-config-and-global-app-content-data.json
index cbebe0fc..731e0817 100644
--- a/firstpartydata/tests/extractfpdforbidders/two-bidders-global-without-config-and-global-app-content-data.json
+++ b/firstpartydata/tests/extractfpdforbidders/two-bidders-global-without-config-and-global-app-content-data.json
@@ -107,7 +107,7 @@
   "biddersFPDResolved": {
     "appnexus": {
       "app": {
-        "id": "reqAppId",
+        "id": "apnFpdAppId",
         "name": "apnFpdAppIdAppName",
         "cat": [
           "books",
@@ -130,24 +130,11 @@
               "name": "apnFpdAppIdContentDataName2"
             }
           ]
-        },
-        "ext": {
-          "data": {
-            "id": "apnFpdAppId",
-            "publisher": {
-              "id": "1"
-            }
-          }
         }
       },
       "user": {
-        "id": "reqUserId",
-        "keywords": "apnFpdUserKeyword",
-        "ext": {
-          "data": {
-            "id":"apnUserId"
-          }
-        }
+        "id": "apnUserId",
+        "keywords": "apnFpdUserKeyword"
       }
     },
     "telaria": {
diff --git a/firstpartydata/tests/extractfpdforbidders/two-bidders-global-without-config-and-global-site-content-data.json b/firstpartydata/tests/extractfpdforbidders/two-bidders-global-without-config-and-global-site-content-data.json
index 717d1741..efb250b7 100644
--- a/firstpartydata/tests/extractfpdforbidders/two-bidders-global-without-config-and-global-site-content-data.json
+++ b/firstpartydata/tests/extractfpdforbidders/two-bidders-global-without-config-and-global-site-content-data.json
@@ -110,7 +110,7 @@
   "biddersFPDResolved": {
     "appnexus": {
       "site": {
-        "id": "reqSiteId",
+        "id": "apnFpdAppId",
         "name": "apnFpdAppIdAppName",
         "page": "http://www.foobar.com/1234.html",
         "cat": [
@@ -134,24 +134,11 @@
               "name": "apnFpdAppIdContentDataName2"
             }
           ]
-        },
-        "ext": {
-          "data": {
-            "id": "apnFpdAppId",
-            "publisher": {
-              "id": "1"
-            }
-          }
         }
       },
       "user": {
-        "id": "reqUserId",
-        "keywords": "apnFpdUserKeyword",
-        "ext": {
-          "data": {
-            "id":"apnUserId"
-          }
-        }
+        "id": "apnUserId",
+        "keywords": "apnFpdUserKeyword"
       }
     },
     "telaria": {
diff --git a/firstpartydata/tests/extractfpdforbidders/two-bidders-no-global-bidder-list.json b/firstpartydata/tests/extractfpdforbidders/two-bidders-no-global-bidder-list.json
index d86db6dc..70798dfd 100644
--- a/firstpartydata/tests/extractfpdforbidders/two-bidders-no-global-bidder-list.json
+++ b/firstpartydata/tests/extractfpdforbidders/two-bidders-no-global-bidder-list.json
@@ -67,24 +67,17 @@
   "biddersFPDResolved": {
     "appnexus": {
       "site": {
-        "id": "reqSiteId",
+        "id": "apnSiteId",
         "page": "http://www.foobar.com/1234.html",
         "publisher": {
           "id": "1"
         },
         "ext": {
-          "data":{
-            "id":"apnSiteId"
-          }
+          "data": "fpdGlobalSiteData"
         }
       },
       "user": {
-        "id": "reqUserId",
-        "ext": {
-          "data":{
-            "id":"apnUserId"
-          }
-        }
+        "id": "apnUserId"
       }
     },
     "telaria": {
diff --git a/firstpartydata/tests/resolvefpd/bidder-fpd-only-app.json b/firstpartydata/tests/resolvefpd/bidder-fpd-only-app.json
index db579fb2..812db7b1 100644
--- a/firstpartydata/tests/resolvefpd/bidder-fpd-only-app.json
+++ b/firstpartydata/tests/resolvefpd/bidder-fpd-only-app.json
@@ -1,44 +1,30 @@
 {
-  "description": "Bidder FPD defined only for app",
-  "inputRequestData": {
-    "app": {
-      "id": "reqUserID"
-    }
-  },
-  "bidderConfigFPD": {
-    "appnexus": {
-      "app": {
-        "id": "apnAppId",
-        "ext": {
-          "data": {
-            "morefpdData": "morefpddata",
-            "appFpddata": "appFpddata",
-            "moreFpd": {
-              "fpd": 123
+    "description": "Bidder FPD defined only for app",
+    "inputRequestData": {
+        "app": {
+            "id": "reqUserID"
+        }
+    },
+    "bidderConfigFPD": {
+        "appnexus": {
+            "app": {
+                "id": "apnAppId",
+                "ext": {
+                    "data": {
+                        "other": "data"
+                    }
+                }
             }
-          }
         }
-      }
-    }
-  },
-  "outputRequestData": {
-    "app": {
-      "id": "reqUserID",
-      "ext": {
-        "data": {
-          "ext": {
-            "data": {
-              "morefpdData": "morefpddata",
-              "appFpddata": "appFpddata",
-              "moreFpd": {
-                "fpd": 123
-              }
+    },
+    "outputRequestData": {
+        "app": {
+            "id": "apnAppId",
+            "ext": {
+                "data": {
+                    "other": "data"
+                }
             }
-          },
-          "id": "apnAppId"
         }
-      }
     }
-  }
-}
-
+}
\ No newline at end of file
diff --git a/firstpartydata/tests/resolvefpd/bidder-fpd-only-site.json b/firstpartydata/tests/resolvefpd/bidder-fpd-only-site.json
index 86a40f29..5accfc3b 100644
--- a/firstpartydata/tests/resolvefpd/bidder-fpd-only-site.json
+++ b/firstpartydata/tests/resolvefpd/bidder-fpd-only-site.json
@@ -1,44 +1,30 @@
 {
-  "description": "Bidder FPD defined only for site",
-  "inputRequestData": {
-    "site": {
-      "id": "reqUserID"
-    }
-  },
-  "bidderConfigFPD": {
-    "appnexus": {
-      "site": {
-        "id": "apnSiteId",
-        "ext": {
-          "data": {
-            "morefpdData": "morefpddata",
-            "siteFpddata": "siteFpddata",
-            "moreFpd": {
-              "fpd": 123
+    "description": "Bidder FPD defined only for site",
+    "inputRequestData": {
+        "site": {
+            "id": "reqUserID"
+        }
+    },
+    "bidderConfigFPD": {
+        "appnexus": {
+            "site": {
+                "id": "apnSiteId",
+                "ext": {
+                    "data": {
+                        "other": "data"
+                    }
+                }
             }
-          }
         }
-      }
-    }
-  },
-  "outputRequestData": {
-    "site": {
-      "id": "reqUserID",
-      "ext": {
-        "data": {
-          "ext": {
-            "data": {
-              "morefpdData": "morefpddata",
-              "siteFpddata": "siteFpddata",
-              "moreFpd": {
-                "fpd": 123
-              }
+    },
+    "outputRequestData": {
+        "site": {
+            "id": "apnSiteId",
+            "ext": {
+                "data": {
+                    "other": "data"
+                }
             }
-          },
-          "id": "apnSiteId"
         }
-      }
     }
-  }
-}
-
+}
\ No newline at end of file
diff --git a/firstpartydata/tests/resolvefpd/bidder-fpd-only-user.json b/firstpartydata/tests/resolvefpd/bidder-fpd-only-user.json
index a1fb4b41..606cee7d 100644
--- a/firstpartydata/tests/resolvefpd/bidder-fpd-only-user.json
+++ b/firstpartydata/tests/resolvefpd/bidder-fpd-only-user.json
@@ -1,49 +1,35 @@
 {
-  "description": "Bidder FPD defined only for user",
-  "inputRequestData": {
-    "user": {
-      "id": "reqUserID",
-      "yob": 1982,
-      "gender": "M"
-    }
-  },
-  "bidderConfigFPD": {
-    "appnexus": {
-      "user": {
-        "id": "apnUserId",
-        "yob": 1982,
-        "ext": {
-          "data": {
-            "morefpdData": "morefpddata",
-            "userFpddata": "userFpddata",
-            "moreFpd": {
-              "fpd": 123
+    "description": "Bidder FPD defined only for user",
+    "inputRequestData": {
+        "user": {
+            "id": "reqUserID",
+            "yob": 1982,
+            "gender": "M"
+        }
+    },
+    "bidderConfigFPD": {
+        "appnexus": {
+            "user": {
+                "id": "apnUserId",
+                "yob": 1982,
+                "ext": {
+                    "data": {
+                        "other": "data"
+                    }
+                }
             }
-          }
         }
-      }
-    }
-  },
-  "outputRequestData": {
-    "user": {
-      "id": "reqUserID",
-      "yob": 1982,
-      "gender": "M",
-      "ext": {
-        "data": {
-          "ext": {
-            "data": {
-              "morefpdData": "morefpddata",
-              "userFpddata": "userFpddata",
-              "moreFpd": {
-                "fpd": 123
-              }
+    },
+    "outputRequestData": {
+        "user": {
+            "id": "apnUserId",
+            "yob": 1982,
+            "gender": "M",
+            "ext": {
+                "data": {
+                    "other": "data"
+                }
             }
-          },
-          "id": "apnUserId"
         }
-      }
     }
-  }
-}
-
+}
\ No newline at end of file
diff --git a/firstpartydata/tests/resolvefpd/bidder-fpd-site-content-data-only.json b/firstpartydata/tests/resolvefpd/bidder-fpd-site-content-data-only.json
index 723d934c..541d51f4 100644
--- a/firstpartydata/tests/resolvefpd/bidder-fpd-site-content-data-only.json
+++ b/firstpartydata/tests/resolvefpd/bidder-fpd-site-content-data-only.json
@@ -48,11 +48,7 @@
         },
         "ext": {
           "data": {
-            "morefpdData": "morefpddata",
-            "siteFpddata": "siteFpddata",
-            "moreFpd": {
-              "fpd": 123
-            }
+            "other": "data"
           }
         }
       }
@@ -65,7 +61,7 @@
   },
   "outputRequestData": {
     "site": {
-      "id": "reqSiteId",
+      "id": "apnSiteId",
       "name": "apnSiteName",
       "domain": "apnSiteDomain",
       "page": "http://www.foobar.com/1234.html",
@@ -92,13 +88,8 @@
       },
       "ext": {
         "data": {
-          "id": "apnSiteId",
-          "morefpdData": "morefpddata",
-          "siteFpddata": "siteFpddata",
-          "testSiteFpd":"testSite",
-          "moreFpd": {
-            "fpd": 123
-          }
+          "other": "data",
+          "testSiteFpd": "testSite"
         }
       }
     },
@@ -109,5 +100,4 @@
       "ifa": "123"
     }
   }
-}
-
+}
\ No newline at end of file
diff --git a/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-app-content-data-user-data.json b/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-app-content-data-user-data.json
index ebba9041..16b5332e 100644
--- a/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-app-content-data-user-data.json
+++ b/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-app-content-data-user-data.json
@@ -11,7 +11,7 @@
     "user": {
       "id": "reqUserID",
       "yob": 1982,
-      "gender": "M"
+      "gender": "reqUserGender"
     }
   },
   "bidderConfigFPD": {
@@ -20,11 +20,7 @@
         "id": "apnAppId",
         "ext": {
           "data": {
-            "morefpdData": "morefpddata",
-            "appFpddata": "appFpddata",
-            "moreFpd": {
-              "fpd": 123
-            }
+            "morefpdData": "morefpddata"
           }
         }
       }
@@ -60,7 +56,7 @@
   },
   "outputRequestData": {
     "app": {
-      "id": "reqAppId",
+      "id": "apnAppId",
       "page": "http://www.foobar.com/1234.html",
       "publisher": {
         "id": "1"
@@ -79,20 +75,15 @@
       },
       "ext": {
         "data": {
-          "moreFpd": {
-            "fpd": 123
-          },
-          "id": "apnAppId",
           "morefpdData": "morefpddata",
-          "appFpd": 123,
-          "appFpddata": "appFpddata"
+          "appFpd": 123
         }
       }
     },
     "user": {
       "id": "reqUserID",
       "yob": 1982,
-      "gender": "M",
+      "gender": "reqUserGender",
       "data": [
         {
           "id": "userData1",
@@ -110,5 +101,4 @@
       }
     }
   }
-}
-
+}
\ No newline at end of file
diff --git a/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-app.json b/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-app.json
index dea87414..609ede59 100644
--- a/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-app.json
+++ b/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-app.json
@@ -45,7 +45,7 @@
   },
   "outputRequestData": {
     "app": {
-      "id": "reqAppId",
+      "id": "apnAppId",
       "page": "http://www.foobar.com/1234.html",
       "publisher": {
         "id": "1"
@@ -59,7 +59,6 @@
       },
       "ext": {
         "data": {
-          "id": "apnAppId",
           "moreFpd": {
             "fpd": 123
           },
diff --git a/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-site-user.json b/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-site-user.json
index fc65518d..f66a8722 100644
--- a/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-site-user.json
+++ b/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-site-user.json
@@ -52,7 +52,7 @@
   },
   "outputRequestData": {
     "site": {
-      "id": "reqSiteId",
+      "id": "apnSiteId",
       "page": "http://www.foobar.com/1234.html",
       "publisher": {
         "id": "1"
@@ -66,16 +66,11 @@
       },
       "ext": {
         "data": {
-          "ext": {
-            "data": {
-              "morefpdData": "morefpddata",
-              "siteFpddata": "siteFpddata",
-              "moreFpd": {
-                "fpd": 123
-              }
-            }
-          },
-          "id": "apnSiteId"
+          "morefpdData": "morefpddata",
+          "siteFpddata": "siteFpddata",
+          "moreFpd": {
+            "fpd": 123
+          }
         }
       }
     },
@@ -83,7 +78,6 @@
       "id": "apnUserId",
       "ext": {
         "data": {
-          "id": "apnUserId",
           "moreFpd": {
             "fpd": 567
           },
diff --git a/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-site.json b/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-site.json
index f3d3fb74..be03f0cb 100644
--- a/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-site.json
+++ b/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-site.json
@@ -41,7 +41,7 @@
   },
   "outputRequestData": {
     "site": {
-      "id": "reqSiteId",
+      "id": "apnSiteId",
       "page": "http://www.foobar.com/1234.html",
       "ref": "fpdRef",
       "publisher": {
@@ -56,7 +56,6 @@
       },
       "ext": {
         "data": {
-          "id": "apnSiteId",
           "moreFpd": {
             "fpd": 123
           },
diff --git a/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-user.json b/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-user.json
index 61251865..ef381d61 100644
--- a/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-user.json
+++ b/firstpartydata/tests/resolvefpd/global-and-bidder-fpd-user.json
@@ -31,12 +31,11 @@
   },
   "outputRequestData": {
     "user": {
-      "id": "reqUserID",
+      "id": "apnUserId",
       "yob": 1982,
       "gender": "M",
       "ext": {
         "data": {
-          "id": "apnUserId",
           "testUserFpd": "testuser",
           "morefpdData": "morefpddata",
           "userFpddata": "siteFpddata",
diff --git a/firstpartydata/tests/resolvefpd/global-and-bidder-site-content-data.json b/firstpartydata/tests/resolvefpd/global-and-bidder-site-content-data.json
index d0154d1b..c0f4723d 100644
--- a/firstpartydata/tests/resolvefpd/global-and-bidder-site-content-data.json
+++ b/firstpartydata/tests/resolvefpd/global-and-bidder-site-content-data.json
@@ -63,7 +63,7 @@
   },
   "outputRequestData": {
     "site": {
-      "id": "reqSiteId",
+      "id": "apnSiteId",
       "page": "http://www.foobar.com/1234.html",
       "ref": "fpdRef",
       "publisher": {
@@ -83,7 +83,6 @@
       },
       "ext": {
         "data": {
-          "id": "apnSiteId",
           "moreFpd": {
             "fpd": 123
           },
diff --git a/firstpartydata/tests/resolvefpd/global-and-no-bidder-fpd-site-app-user.json b/firstpartydata/tests/resolvefpd/global-and-no-bidder-fpd-site-app-user.json
index 17acd451..a705e8b2 100644
--- a/firstpartydata/tests/resolvefpd/global-and-no-bidder-fpd-site-app-user.json
+++ b/firstpartydata/tests/resolvefpd/global-and-no-bidder-fpd-site-app-user.json
@@ -73,7 +73,7 @@
   },
   "outputRequestData": {
     "site": {
-      "id": "reqSiteId",
+      "id": "apnSiteId",
       "page": "http://www.foobar.com/1234.html",
       "ref": "fpdRef",
       "publisher": {
@@ -99,7 +99,6 @@
       "ext": {
         "testSiteExt": 123,
         "data": {
-          "id": "apnSiteId",
           "moreFpd": {
             "fpd": 123
           },
diff --git a/firstpartydata/tests/resolvefpd/global-and-no-bidder-fpd-site-content-app-user.json b/firstpartydata/tests/resolvefpd/global-and-no-bidder-fpd-site-content-app-user.json
index 80b47a56..ccfa030c 100644
--- a/firstpartydata/tests/resolvefpd/global-and-no-bidder-fpd-site-content-app-user.json
+++ b/firstpartydata/tests/resolvefpd/global-and-no-bidder-fpd-site-content-app-user.json
@@ -66,7 +66,7 @@
   },
   "outputRequestData": {
     "site": {
-      "id": "reqSiteId",
+      "id": "apnSiteId",
       "page": "http://www.foobar.com/1234.html",
       "ref": "fpdRef",
       "publisher": {
@@ -87,7 +87,6 @@
       "ext": {
         "testSiteExt": 123,
         "data": {
-          "id": "apnSiteId",
           "moreFpd": {
             "fpd": 123
           },
diff --git a/firstpartydata/tests/resolvefpd/global-fpd-only-app.json b/firstpartydata/tests/resolvefpd/global-fpd-only-app.json
index d7bd3589..e4d5e169 100644
--- a/firstpartydata/tests/resolvefpd/global-fpd-only-app.json
+++ b/firstpartydata/tests/resolvefpd/global-fpd-only-app.json
@@ -65,7 +65,7 @@
   },
   "outputRequestData": {
     "app": {
-      "id": "reqAppId",
+      "id": "apnAppId",
       "publisher": {
         "id": "1"
       },
@@ -89,7 +89,6 @@
       "ext": {
         "testAppExt": 123,
         "data": {
-          "id": "apnAppId",
           "moreFpd": {
             "fpd": 123
           },
diff --git a/firstpartydata/tests/resolvefpd/global-fpd-only-site.json b/firstpartydata/tests/resolvefpd/global-fpd-only-site.json
index 73f82788..49b8f4dc 100644
--- a/firstpartydata/tests/resolvefpd/global-fpd-only-site.json
+++ b/firstpartydata/tests/resolvefpd/global-fpd-only-site.json
@@ -73,7 +73,7 @@
   },
   "outputRequestData": {
     "site": {
-      "id": "reqSiteId",
+      "id": "apnSiteId",
       "page": "http://www.foobar.com/1234.html",
       "ref": "fpdRef",
       "publisher": {
@@ -99,7 +99,6 @@
       "ext": {
         "testSiteExt": 123,
         "data": {
-          "id": "apnSiteId",
           "moreFpd": {
             "fpd": 123
           },
diff --git a/firstpartydata/tests/resolvefpd/req-and-bidder-fpd-site-content.json b/firstpartydata/tests/resolvefpd/req-and-bidder-fpd-site-content.json
index f3e5fdd2..446e7151 100644
--- a/firstpartydata/tests/resolvefpd/req-and-bidder-fpd-site-content.json
+++ b/firstpartydata/tests/resolvefpd/req-and-bidder-fpd-site-content.json
@@ -90,7 +90,7 @@
   },
   "outputRequestData": {
     "site": {
-      "id": "reqSiteId",
+      "id": "apnSiteId",
       "page": "http://www.foobar.com/1234.html",
       "ref": "fpdRef",
       "publisher": {
@@ -116,7 +116,6 @@
       "ext": {
         "testSiteExt": 123,
         "data": {
-          "id": "apnSiteId",
           "moreFpd": {
             "fpd": 123
           },
diff --git a/floors/enforce_test.go b/floors/enforce_test.go
index 6768f1e3..bb5d4434 100644
--- a/floors/enforce_test.go
+++ b/floors/enforce_test.go
@@ -431,7 +431,6 @@ func TestEnforce(t *testing.T) {
 					Currency: "USD",
 				},
 			},
-			expErrs:         []error{errors.New("Floors feature is disabled at account or in the request")},
 			expRejectedBids: []*entities.PbsOrtbSeatBid{},
 		},
 		{
@@ -470,7 +469,6 @@ func TestEnforce(t *testing.T) {
 					Currency: "USD",
 				},
 			},
-			expErrs:         []error{errors.New("Floors feature is disabled at account or in the request")},
 			expRejectedBids: []*entities.PbsOrtbSeatBid{},
 		},
 		{
diff --git a/gdpr/gdpr_test.go b/gdpr/gdpr_test.go
index 138da5d9..1e56c5fd 100644
--- a/gdpr/gdpr_test.go
+++ b/gdpr/gdpr_test.go
@@ -43,7 +43,7 @@ func TestNewPermissions(t *testing.T) {
 			HostVendorID: tt.hostVendorID,
 		}
 		vendorIDs := map[openrtb_ext.BidderName]uint16{}
-		vendorListFetcher := func(ctx context.Context, id uint16) (vendorlist.VendorList, error) {
+		vendorListFetcher := func(ctx context.Context, specVersion, listVersion uint16) (vendorlist.VendorList, error) {
 			return nil, nil
 		}
 
diff --git a/gdpr/impl_test.go b/gdpr/impl_test.go
index 3512a271..4a65dbaf 100644
--- a/gdpr/impl_test.go
+++ b/gdpr/impl_test.go
@@ -81,8 +81,10 @@ func TestAllowedSyncs(t *testing.T) {
 		vendorIDs: map[openrtb_ext.BidderName]uint16{
 			openrtb_ext.BidderAppnexus: 2,
 		},
-		fetchVendorList: listFetcher(map[uint16]vendorlist.VendorList{
-			1: parseVendorListDataV2(t, vendorListData),
+		fetchVendorList: listFetcher(map[uint16]map[uint16]vendorlist.VendorList{
+			2: {
+				1: parseVendorListDataV2(t, vendorListData),
+			},
 		}),
 		purposeEnforcerBuilder: NewPurposeEnforcerBuilder(&tcf2AggConfig),
 		gdprSignal:             SignalYes,
@@ -127,8 +129,10 @@ func TestProhibitedPurposes(t *testing.T) {
 		vendorIDs: map[openrtb_ext.BidderName]uint16{
 			openrtb_ext.BidderAppnexus: 2,
 		},
-		fetchVendorList: listFetcher(map[uint16]vendorlist.VendorList{
-			1: parseVendorListDataV2(t, vendorListData),
+		fetchVendorList: listFetcher(map[uint16]map[uint16]vendorlist.VendorList{
+			2: {
+				1: parseVendorListDataV2(t, vendorListData),
+			},
 		}),
 		purposeEnforcerBuilder: NewPurposeEnforcerBuilder(&tcf2AggConfig),
 		gdprSignal:             SignalYes,
@@ -174,8 +178,10 @@ func TestProhibitedVendors(t *testing.T) {
 		vendorIDs: map[openrtb_ext.BidderName]uint16{
 			openrtb_ext.BidderAppnexus: 2,
 		},
-		fetchVendorList: listFetcher(map[uint16]vendorlist.VendorList{
-			1: parseVendorListDataV2(t, vendorListData),
+		fetchVendorList: listFetcher(map[uint16]map[uint16]vendorlist.VendorList{
+			2: {
+				1: parseVendorListDataV2(t, vendorListData),
+			},
 		}),
 		purposeEnforcerBuilder: NewPurposeEnforcerBuilder(&tcf2AggConfig),
 		gdprSignal:             SignalYes,
@@ -315,8 +321,10 @@ func TestAllowActivities(t *testing.T) {
 		vendorIDs: map[openrtb_ext.BidderName]uint16{
 			openrtb_ext.BidderAppnexus: 2,
 		},
-		fetchVendorList: listFetcher(map[uint16]vendorlist.VendorList{
-			1: parseVendorListDataV2(t, vendorListData),
+		fetchVendorList: listFetcher(map[uint16]map[uint16]vendorlist.VendorList{
+			2: {
+				1: parseVendorListDataV2(t, vendorListData),
+			},
 		}),
 		purposeEnforcerBuilder: NewPurposeEnforcerBuilder(&tcf2AggConfig),
 	}
@@ -444,9 +452,11 @@ func TestAllowActivitiesGeoAndID(t *testing.T) {
 			openrtb_ext.BidderOpenx:           20,
 			openrtb_ext.BidderAudienceNetwork: 55,
 		},
-		fetchVendorList: listFetcher(map[uint16]vendorlist.VendorList{
-			34: parseVendorListDataV2(t, vendorListData),
-			74: parseVendorListDataV2(t, vendorListData),
+		fetchVendorList: listFetcher(map[uint16]map[uint16]vendorlist.VendorList{
+			2: {
+				34: parseVendorListDataV2(t, vendorListData),
+				74: parseVendorListDataV2(t, vendorListData),
+			},
 		}),
 		gdprSignal: SignalYes,
 	}
@@ -569,8 +579,10 @@ func TestAllowActivitiesWhitelist(t *testing.T) {
 			openrtb_ext.BidderPubmatic: 6,
 			openrtb_ext.BidderRubicon:  8,
 		},
-		fetchVendorList: listFetcher(map[uint16]vendorlist.VendorList{
-			34: parseVendorListDataV2(t, vendorListData),
+		fetchVendorList: listFetcher(map[uint16]map[uint16]vendorlist.VendorList{
+			2: {
+				34: parseVendorListDataV2(t, vendorListData),
+			},
 		}),
 		purposeEnforcerBuilder: NewPurposeEnforcerBuilder(&tcf2AggConfig),
 		aliasGVLIDs:            map[string]uint16{},
@@ -598,8 +610,10 @@ func TestAllowActivitiesPubRestrict(t *testing.T) {
 			openrtb_ext.BidderPubmatic: 32,
 			openrtb_ext.BidderRubicon:  8,
 		},
-		fetchVendorList: listFetcher(map[uint16]vendorlist.VendorList{
-			15: parseVendorListDataV2(t, vendorListData),
+		fetchVendorList: listFetcher(map[uint16]map[uint16]vendorlist.VendorList{
+			2: {
+				15: parseVendorListDataV2(t, vendorListData),
+			},
 		}),
 		purposeEnforcerBuilder: NewPurposeEnforcerBuilder(&tcf2AggConfig),
 		gdprSignal:             SignalYes,
@@ -671,8 +685,10 @@ func TestAllowSync(t *testing.T) {
 			openrtb_ext.BidderPubmatic: 6,
 			openrtb_ext.BidderRubicon:  8,
 		},
-		fetchVendorList: listFetcher(map[uint16]vendorlist.VendorList{
-			34: parseVendorListDataV2(t, vendorListData),
+		fetchVendorList: listFetcher(map[uint16]map[uint16]vendorlist.VendorList{
+			2: {
+				34: parseVendorListDataV2(t, vendorListData),
+			},
 		}),
 		purposeEnforcerBuilder: NewPurposeEnforcerBuilder(&tcf2AggConfig),
 		gdprSignal:             SignalYes,
@@ -705,8 +721,10 @@ func TestProhibitedPurposeSync(t *testing.T) {
 			openrtb_ext.BidderPubmatic: 6,
 			openrtb_ext.BidderRubicon:  8,
 		},
-		fetchVendorList: listFetcher(map[uint16]vendorlist.VendorList{
-			34: parseVendorListDataV2(t, vendorListData),
+		fetchVendorList: listFetcher(map[uint16]map[uint16]vendorlist.VendorList{
+			2: {
+				34: parseVendorListDataV2(t, vendorListData),
+			},
 		}),
 		purposeEnforcerBuilder: NewPurposeEnforcerBuilder(&tcf2AggConfig),
 		gdprSignal:             SignalYes,
@@ -737,8 +755,10 @@ func TestProhibitedVendorSync(t *testing.T) {
 			openrtb_ext.BidderRubicon:  8,
 			openrtb_ext.BidderOpenx:    10,
 		},
-		fetchVendorList: listFetcher(map[uint16]vendorlist.VendorList{
-			34: parseVendorListDataV2(t, vendorListData),
+		fetchVendorList: listFetcher(map[uint16]map[uint16]vendorlist.VendorList{
+			2: {
+				34: parseVendorListDataV2(t, vendorListData),
+			},
 		}),
 		purposeEnforcerBuilder: NewPurposeEnforcerBuilder(&tcf2AggConfig),
 		gdprSignal:             SignalYes,
@@ -765,18 +785,18 @@ func parseVendorListDataV2(t *testing.T, data string) vendorlist.VendorList {
 	return parsed
 }
 
-func listFetcher(lists map[uint16]vendorlist.VendorList) func(context.Context, uint16) (vendorlist.VendorList, error) {
-	return func(ctx context.Context, id uint16) (vendorlist.VendorList, error) {
-		data, ok := lists[id]
-		if ok {
-			return data, nil
-		} else {
-			return nil, fmt.Errorf("vendorlist id=%d not found", id)
+func listFetcher(specVersionLists map[uint16]map[uint16]vendorlist.VendorList) func(context.Context, uint16, uint16) (vendorlist.VendorList, error) {
+	return func(ctx context.Context, specVersion, listVersion uint16) (vendorlist.VendorList, error) {
+		if lists, ok := specVersionLists[specVersion]; ok {
+			if data, ok := lists[listVersion]; ok {
+				return data, nil
+			}
 		}
+		return nil, fmt.Errorf("spec version %d vendor list %d not found", specVersion, listVersion)
 	}
 }
 
-func failedListFetcher(ctx context.Context, id uint16) (vendorlist.VendorList, error) {
+func failedListFetcher(ctx context.Context, specVersion, listVersion uint16) (vendorlist.VendorList, error) {
 	return nil, errors.New("vendor list can't be fetched")
 }
 
@@ -957,8 +977,10 @@ func TestAllowActivitiesBidRequests(t *testing.T) {
 				openrtb_ext.BidderPubmatic: 6,
 				openrtb_ext.BidderRubicon:  8,
 			},
-			fetchVendorList: listFetcher(map[uint16]vendorlist.VendorList{
-				34: parseVendorListDataV2(t, vendorListData),
+			fetchVendorList: listFetcher(map[uint16]map[uint16]vendorlist.VendorList{
+				2: {
+					34: parseVendorListDataV2(t, vendorListData),
+				},
 			}),
 			aliasGVLIDs: td.aliasGVLIDs,
 			consent:     td.consent,
@@ -1073,8 +1095,10 @@ func TestAllowActivitiesVendorException(t *testing.T) {
 			vendorIDs: map[openrtb_ext.BidderName]uint16{
 				openrtb_ext.BidderAppnexus: 32,
 			},
-			fetchVendorList: listFetcher(map[uint16]vendorlist.VendorList{
-				34: parseVendorListDataV2(t, vendorListData),
+			fetchVendorList: listFetcher(map[uint16]map[uint16]vendorlist.VendorList{
+				2: {
+					34: parseVendorListDataV2(t, vendorListData),
+				},
 			}),
 			aliasGVLIDs: map[string]uint16{},
 			consent:     td.consent,
@@ -1139,8 +1163,10 @@ func TestBidderSyncAllowedVendorException(t *testing.T) {
 			vendorIDs: map[openrtb_ext.BidderName]uint16{
 				openrtb_ext.BidderAppnexus: 32,
 			},
-			fetchVendorList: listFetcher(map[uint16]vendorlist.VendorList{
-				34: parseVendorListDataV2(t, vendorListData),
+			fetchVendorList: listFetcher(map[uint16]map[uint16]vendorlist.VendorList{
+				2: {
+					34: parseVendorListDataV2(t, vendorListData),
+				},
 			}),
 			consent:    td.consent,
 			gdprSignal: SignalYes,
@@ -1217,3 +1243,139 @@ func TestDefaultPermissions(t *testing.T) {
 		assert.Equal(t, result, tt.wantPermissions, tt.description)
 	}
 }
+
+func TestGetSpecVersion(t *testing.T) {
+	tests := []struct {
+		name                string
+		policyVersion       uint8
+		expectedSpecVersion uint16
+		expectedError       bool
+	}{
+		{
+			name:                "policy_version_0_gives_spec_version_2",
+			policyVersion:       0,
+			expectedSpecVersion: 2,
+		},
+		{
+			name:                "policy_version_3_gives_spec_version_2",
+			policyVersion:       3,
+			expectedSpecVersion: 2,
+		},
+		{
+			name:                "policy_version_4_spec_version_3",
+			policyVersion:       4,
+			expectedSpecVersion: 3,
+		},
+		{
+			name:                "policy_version_5_error",
+			policyVersion:       5,
+			expectedSpecVersion: 0,
+			expectedError:       true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			specVersion, err := getSpecVersion(tt.policyVersion)
+			assert.Equal(t, tt.expectedSpecVersion, specVersion)
+			if tt.expectedError {
+				assert.Error(t, err)
+			} else {
+				assert.Nil(t, err)
+			}
+		})
+	}
+}
+
+func TestVendorListSelection(t *testing.T) {
+	policyVersion3WithVendor2AndPurpose1Consent := "CPGWbY_PGWbY_GYAAAENABDAAIAAAAAAAAAAACEAAAAA"
+	policyVersion4WithVendor2AndPurpose1Consent := "CPGWbY_PGWbY_GYAAAENABEAAIAAAAAAAAAAACEAAAAA"
+	policyVersion5WithVendor2AndPurpose1Consent := "CPGWbY_PGWbY_GYAAAENABFAAIAAAAAAAAAAACEAAAAA"
+
+	specVersion2vendorListData := MarshalVendorList(vendorList{
+		GVLSpecificationVersion: 2,
+		VendorListVersion:       2,
+		Vendors: map[string]*vendor{
+			"2": {
+				ID:       2,
+				Purposes: []int{},
+			},
+		},
+	})
+	specVersion3vendorListData := MarshalVendorList(vendorList{
+		GVLSpecificationVersion: 3,
+		VendorListVersion:       2,
+		Vendors: map[string]*vendor{
+			"2": {
+				ID:       2,
+				Purposes: []int{1},
+			},
+		},
+	})
+
+	tcf2AggConfig := tcf2Config{
+		HostConfig: config.TCF2{
+			Purpose1: config.TCF2Purpose{
+				EnforcePurpose: true,
+				EnforceVendors: true,
+			},
+		},
+	}
+	tcf2AggConfig.HostConfig.PurposeConfigs = map[consentconstants.Purpose]*config.TCF2Purpose{
+		consentconstants.Purpose(1): &tcf2AggConfig.HostConfig.Purpose1,
+	}
+
+	perms := permissionsImpl{
+		cfg:          &tcf2AggConfig,
+		hostVendorID: 2,
+		vendorIDs: map[openrtb_ext.BidderName]uint16{
+			openrtb_ext.BidderAppnexus: 2,
+		},
+		fetchVendorList: listFetcher(map[uint16]map[uint16]vendorlist.VendorList{
+			2: {
+				1: parseVendorListDataV2(t, specVersion2vendorListData),
+			},
+			3: {
+				1: parseVendorListDataV2(t, specVersion3vendorListData),
+			},
+		}),
+		purposeEnforcerBuilder: NewPurposeEnforcerBuilder(&tcf2AggConfig),
+		gdprSignal:             SignalYes,
+	}
+
+	tests := []struct {
+		name              string
+		consent           string
+		expectedAllowSync bool
+		expectedErr       bool
+	}{
+		{
+			name:              "consent_tcf_policy_version_3_uses_gvl_spec_version_2",
+			consent:           policyVersion3WithVendor2AndPurpose1Consent,
+			expectedAllowSync: false,
+		},
+		{
+			name:              "consent_tcf_policy_version_4_uses_gvl_spec_version_3",
+			consent:           policyVersion4WithVendor2AndPurpose1Consent,
+			expectedAllowSync: true,
+		},
+		{
+			name:        "consent_tcf_policy_version_5_causes_error",
+			consent:     policyVersion5WithVendor2AndPurpose1Consent,
+			expectedErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			perms.consent = tt.consent
+			allowSync, err := perms.HostCookiesAllowed(context.Background())
+			assert.Equal(t, tt.expectedAllowSync, allowSync)
+			if tt.expectedErr {
+				assert.Error(t, err)
+			} else {
+				assert.Nil(t, err)
+			}
+		})
+	}
+}
diff --git a/gdpr/vendorlist-fetching_test.go b/gdpr/vendorlist-fetching_test.go
index 2c5eb429..a1dfb7fe 100644
--- a/gdpr/vendorlist-fetching_test.go
+++ b/gdpr/vendorlist-fetching_test.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 
+	"github.com/prebid/go-gdpr/api"
 	"github.com/prebid/go-gdpr/consentconstants"
 	"github.com/prebid/prebid-server/config"
 )
@@ -20,9 +21,11 @@ func TestFetcherDynamicLoadListExists(t *testing.T) {
 
 	server := httptest.NewServer(http.HandlerFunc(mockServer(serverSettings{
 		vendorListLatestVersion: 1,
-		vendorLists: map[int]string{
-			1: vendorList1,
-			2: vendorList2,
+		vendorLists: map[int]map[int]string{
+			3: {
+				1: vendorList1,
+				2: vendorList2,
+			},
 		},
 	})))
 	defer server.Close()
@@ -30,7 +33,8 @@ func TestFetcherDynamicLoadListExists(t *testing.T) {
 	test := test{
 		description: "Dynamic Load - List Exists",
 		setup: testSetup{
-			vendorListVersion: 2,
+			specVersion: 3,
+			listVersion: 2,
 		},
 		expected: vendorList2Expected,
 	}
@@ -44,8 +48,10 @@ func TestFetcherDynamicLoadListDoesntExist(t *testing.T) {
 
 	server := httptest.NewServer(http.HandlerFunc(mockServer(serverSettings{
 		vendorListLatestVersion: 1,
-		vendorLists: map[int]string{
-			1: vendorList1,
+		vendorLists: map[int]map[int]string{
+			3: {
+				1: vendorList1,
+			},
 		},
 	})))
 	defer server.Close()
@@ -53,10 +59,11 @@ func TestFetcherDynamicLoadListDoesntExist(t *testing.T) {
 	test := test{
 		description: "No Fallback - Vendor Doesn't Exist",
 		setup: testSetup{
-			vendorListVersion: 2,
+			specVersion: 3,
+			listVersion: 2,
 		},
 		expected: testExpected{
-			errorMessage: "gdpr vendor list version 2 does not exist, or has not been loaded yet. Try again in a few minutes",
+			errorMessage: "gdpr vendor list spec version 3 list version 2 does not exist, or has not been loaded yet. Try again in a few minutes",
 		},
 	}
 
@@ -66,19 +73,24 @@ func TestFetcherDynamicLoadListDoesntExist(t *testing.T) {
 func TestFetcherThrottling(t *testing.T) {
 	server := httptest.NewServer(http.HandlerFunc(mockServer(serverSettings{
 		vendorListLatestVersion: 1,
-		vendorLists: map[int]string{
-			1: MarshalVendorList(vendorList{
-				VendorListVersion: 1,
-				Vendors:           map[string]*vendor{"12": {ID: 12, Purposes: []int{1}}},
-			}),
-			2: MarshalVendorList(vendorList{
-				VendorListVersion: 2,
-				Vendors:           map[string]*vendor{"12": {ID: 12, Purposes: []int{1, 2}}},
-			}),
-			3: MarshalVendorList(vendorList{
-				VendorListVersion: 3,
-				Vendors:           map[string]*vendor{"12": {ID: 12, Purposes: []int{1, 2, 3}}},
-			}),
+		vendorLists: map[int]map[int]string{
+			3: {
+				1: MarshalVendorList(vendorList{
+					GVLSpecificationVersion: 3,
+					VendorListVersion:       1,
+					Vendors:                 map[string]*vendor{"12": {ID: 12, Purposes: []int{1}}},
+				}),
+				2: MarshalVendorList(vendorList{
+					GVLSpecificationVersion: 3,
+					VendorListVersion:       2,
+					Vendors:                 map[string]*vendor{"12": {ID: 12, Purposes: []int{1, 2}}},
+				}),
+				3: MarshalVendorList(vendorList{
+					GVLSpecificationVersion: 3,
+					VendorListVersion:       3,
+					Vendors:                 map[string]*vendor{"12": {ID: 12, Purposes: []int{1, 2, 3}}},
+				}),
+			},
 		},
 	})))
 	defer server.Close()
@@ -86,26 +98,28 @@ func TestFetcherThrottling(t *testing.T) {
 	fetcher := NewVendorListFetcher(context.Background(), testConfig(), server.Client(), testURLMaker(server))
 
 	// Dynamically Load List 2 Successfully
-	_, errList1 := fetcher(context.Background(), 2)
+	_, errList1 := fetcher(context.Background(), 3, 2)
 	assert.NoError(t, errList1)
 
 	// Fail To Load List 3 Due To Rate Limiting
 	// - The request is rate limited after dynamically list 2.
-	_, errList2 := fetcher(context.Background(), 3)
-	assert.EqualError(t, errList2, "gdpr vendor list version 3 does not exist, or has not been loaded yet. Try again in a few minutes")
+	_, errList2 := fetcher(context.Background(), 3, 3)
+	assert.EqualError(t, errList2, "gdpr vendor list spec version 3 list version 3 does not exist, or has not been loaded yet. Try again in a few minutes")
 }
 
 func TestMalformedVendorlist(t *testing.T) {
 	server := httptest.NewServer(http.HandlerFunc(mockServer(serverSettings{
 		vendorListLatestVersion: 1,
-		vendorLists: map[int]string{
-			1: "malformed",
+		vendorLists: map[int]map[int]string{
+			3: {
+				1: "malformed",
+			},
 		},
 	})))
 	defer server.Close()
 
 	fetcher := NewVendorListFetcher(context.Background(), testConfig(), server.Client(), testURLMaker(server))
-	_, err := fetcher(context.Background(), 1)
+	_, err := fetcher(context.Background(), 3, 1)
 
 	// Fetching should fail since vendor list could not be unmarshalled.
 	assert.Error(t, err)
@@ -115,12 +129,12 @@ func TestServerUrlInvalid(t *testing.T) {
 	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
 	server.Close()
 
-	invalidURLGenerator := func(uint16) string { return " http://invalid-url-has-leading-whitespace" }
+	invalidURLGenerator := func(uint16, uint16) string { return " http://invalid-url-has-leading-whitespace" }
 
 	fetcher := NewVendorListFetcher(context.Background(), testConfig(), server.Client(), invalidURLGenerator)
-	_, err := fetcher(context.Background(), 1)
+	_, err := fetcher(context.Background(), 3, 1)
 
-	assert.EqualError(t, err, "gdpr vendor list version 1 does not exist, or has not been loaded yet. Try again in a few minutes")
+	assert.EqualError(t, err, "gdpr vendor list spec version 3 list version 1 does not exist, or has not been loaded yet. Try again in a few minutes")
 }
 
 func TestServerUnavailable(t *testing.T) {
@@ -128,43 +142,140 @@ func TestServerUnavailable(t *testing.T) {
 	server.Close()
 
 	fetcher := NewVendorListFetcher(context.Background(), testConfig(), server.Client(), testURLMaker(server))
-	_, err := fetcher(context.Background(), 1)
+	_, err := fetcher(context.Background(), 3, 1)
 
-	assert.EqualError(t, err, "gdpr vendor list version 1 does not exist, or has not been loaded yet. Try again in a few minutes")
+	assert.EqualError(t, err, "gdpr vendor list spec version 3 list version 1 does not exist, or has not been loaded yet. Try again in a few minutes")
 }
 
 func TestVendorListURLMaker(t *testing.T) {
 	testCases := []struct {
-		description       string
-		vendorListVersion uint16
-		expectedURL       string
+		description string
+		specVersion uint16
+		listVersion uint16
+		expectedURL string
 	}{
 		{
-			description:       "Latest",
-			vendorListVersion: 0,
-			expectedURL:       "https://vendor-list.consensu.org/v2/vendor-list.json",
+			description: "Spec version 2 latest list",
+			specVersion: 2,
+			listVersion: 0,
+			expectedURL: "https://vendor-list.consensu.org/v2/vendor-list.json",
 		},
 		{
-			description:       "Specific",
-			vendorListVersion: 42,
-			expectedURL:       "https://vendor-list.consensu.org/v2/archives/vendor-list-v42.json",
+			description: "Spec version 2 specific list",
+			specVersion: 2,
+			listVersion: 42,
+			expectedURL: "https://vendor-list.consensu.org/v2/archives/vendor-list-v42.json",
+		},
+		{
+			description: "Spec version 3 latest list",
+			specVersion: 3,
+			listVersion: 0,
+			expectedURL: "https://vendor-list.consensu.org/v3/vendor-list.json",
+		},
+		{
+			description: "Spec version 3 specific list",
+			specVersion: 3,
+			listVersion: 42,
+			expectedURL: "https://vendor-list.consensu.org/v3/archives/vendor-list-v42.json",
 		},
 	}
 
 	for _, test := range testCases {
-		result := VendorListURLMaker(test.vendorListVersion)
+		result := VendorListURLMaker(test.specVersion, test.listVersion)
 		assert.Equal(t, test.expectedURL, result)
 	}
 }
 
+type versionInfo struct {
+	specVersion uint16
+	listVersion uint16
+}
+type saver []versionInfo
+
+func (s *saver) saveVendorLists(specVersion uint16, listVersion uint16, gvl api.VendorList) {
+	vi := versionInfo{
+		specVersion: specVersion,
+		listVersion: listVersion,
+	}
+	*s = append(*s, vi)
+}
+
+func TestPreloadCache(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(mockServer(serverSettings{
+		vendorListLatestVersion: 3,
+		vendorLists: map[int]map[int]string{
+			1: {
+				1: MarshalVendorList(vendorList{
+					GVLSpecificationVersion: 1, VendorListVersion: 1,
+				}),
+				2: MarshalVendorList(vendorList{
+					GVLSpecificationVersion: 1, VendorListVersion: 2,
+				}),
+				3: MarshalVendorList(vendorList{
+					GVLSpecificationVersion: 1, VendorListVersion: 3,
+				}),
+			},
+			2: {
+				1: MarshalVendorList(vendorList{
+					GVLSpecificationVersion: 2, VendorListVersion: 1,
+				}),
+				2: MarshalVendorList(vendorList{
+					GVLSpecificationVersion: 2, VendorListVersion: 2,
+				}),
+				3: MarshalVendorList(vendorList{
+					GVLSpecificationVersion: 2, VendorListVersion: 3,
+				}),
+			},
+			3: {
+				1: MarshalVendorList(vendorList{
+					GVLSpecificationVersion: 3, VendorListVersion: 1,
+				}),
+				2: MarshalVendorList(vendorList{
+					GVLSpecificationVersion: 3, VendorListVersion: 2,
+				}),
+				3: MarshalVendorList(vendorList{
+					GVLSpecificationVersion: 3, VendorListVersion: 3,
+				}),
+			},
+			4: {
+				1: MarshalVendorList(vendorList{
+					GVLSpecificationVersion: 4, VendorListVersion: 1,
+				}),
+				2: MarshalVendorList(vendorList{
+					GVLSpecificationVersion: 4, VendorListVersion: 2,
+				}),
+				3: MarshalVendorList(vendorList{
+					GVLSpecificationVersion: 4, VendorListVersion: 3,
+				}),
+			},
+		},
+	})))
+	defer server.Close()
+
+	s := make(saver, 0, 5)
+	preloadCache(context.Background(), server.Client(), testURLMaker(server), s.saveVendorLists)
+
+	expectedLoadedVersions := []versionInfo{
+		{specVersion: 2, listVersion: 2},
+		{specVersion: 2, listVersion: 3},
+		{specVersion: 3, listVersion: 1},
+		{specVersion: 3, listVersion: 2},
+		{specVersion: 3, listVersion: 3},
+	}
+
+	assert.ElementsMatch(t, expectedLoadedVersions, s)
+}
+
 var vendorList1 = MarshalVendorList(vendorList{
-	VendorListVersion: 1,
-	Vendors:           map[string]*vendor{"12": {ID: 12, Purposes: []int{2}}},
+	GVLSpecificationVersion: 3,
+	VendorListVersion:       1,
+	Vendors:                 map[string]*vendor{"12": {ID: 12, Purposes: []int{2}}},
 })
 
 var vendorList2 = MarshalVendorList(vendorList{
-	VendorListVersion: 2,
-	Vendors:           map[string]*vendor{"12": {ID: 12, Purposes: []int{2, 3}}},
+	GVLSpecificationVersion: 3,
+	VendorListVersion:       2,
+	Vendors:                 map[string]*vendor{"12": {ID: 12, Purposes: []int{2, 3}}},
 })
 
 var vendorList2Expected = testExpected{
@@ -180,8 +291,9 @@ var vendorListFallbackExpected = testExpected{
 }
 
 type vendorList struct {
-	VendorListVersion uint16             `json:"vendorListVersion"`
-	Vendors           map[string]*vendor `json:"vendors"`
+	GVLSpecificationVersion uint16             `json:"gvlSpecificationVersion"`
+	VendorListVersion       uint16             `json:"vendorListVersion"`
+	Vendors                 map[string]*vendor `json:"vendors"`
 }
 
 type vendor struct {
@@ -199,7 +311,7 @@ func MarshalVendorList(vendorList vendorList) string {
 
 type serverSettings struct {
 	vendorListLatestVersion int
-	vendorLists             map[int]string
+	vendorLists             map[int]map[int]string
 }
 
 // mockServer returns a handler which returns the given response for each global vendor list version.
@@ -215,20 +327,33 @@ type serverSettings struct {
 // Don't ask why... that's just what the official page is doing. See https://vendor-list.consensu.org/v-9999/vendorlist.json
 func mockServer(settings serverSettings) func(http.ResponseWriter, *http.Request) {
 	return func(w http.ResponseWriter, req *http.Request) {
-		vendorListVersion := req.URL.Query().Get("version")
-		vendorListVersionInt, err := strconv.Atoi(vendorListVersion)
+		specVersion := req.URL.Query().Get("specversion")
+		specVersionInt, err := strconv.Atoi(specVersion)
+		if err != nil {
+			w.WriteHeader(http.StatusBadRequest)
+			w.Write([]byte("Request had invalid spec version: " + specVersion))
+			return
+		}
+		listVersion := req.URL.Query().Get("listversion")
+		listVersionInt, err := strconv.Atoi(listVersion)
 		if err != nil {
 			w.WriteHeader(http.StatusBadRequest)
-			w.Write([]byte("Request had invalid version: " + vendorListVersion))
+			w.Write([]byte("Request had invalid version: " + listVersion))
 			return
 		}
-		if vendorListVersionInt == 0 {
-			vendorListVersionInt = settings.vendorListLatestVersion
+		if listVersionInt == 0 {
+			listVersionInt = settings.vendorListLatestVersion
 		}
-		response, ok := settings.vendorLists[vendorListVersionInt]
+		specVersionVendorLists, ok := settings.vendorLists[specVersionInt]
 		if !ok {
 			w.WriteHeader(http.StatusForbidden)
-			w.Write([]byte("Version not found: " + vendorListVersion))
+			w.Write([]byte("Version not found: spec version " + specVersion + " list version " + listVersion))
+			return
+		}
+		response, ok := specVersionVendorLists[listVersionInt]
+		if !ok {
+			w.WriteHeader(http.StatusForbidden)
+			w.Write([]byte("Version not found: " + listVersion))
 			return
 		}
 		w.Write([]byte(response))
@@ -242,7 +367,8 @@ type test struct {
 }
 
 type testSetup struct {
-	vendorListVersion uint16
+	specVersion uint16
+	listVersion uint16
 }
 
 type testExpected struct {
@@ -255,7 +381,7 @@ type testExpected struct {
 func runTest(t *testing.T, test test, server *httptest.Server) {
 	config := testConfig()
 	fetcher := NewVendorListFetcher(context.Background(), config, server.Client(), testURLMaker(server))
-	vendorList, err := fetcher(context.Background(), test.setup.vendorListVersion)
+	vendorList, err := fetcher(context.Background(), test.setup.specVersion, test.setup.listVersion)
 
 	if test.expected.errorMessage != "" {
 		assert.EqualError(t, err, test.expected.errorMessage, test.description+":error")
@@ -270,10 +396,10 @@ func runTest(t *testing.T, test test, server *httptest.Server) {
 	}
 }
 
-func testURLMaker(server *httptest.Server) func(uint16) string {
+func testURLMaker(server *httptest.Server) func(uint16, uint16) string {
 	url := server.URL
-	return func(vendorListVersion uint16) string {
-		return url + "?version=" + strconv.Itoa(int(vendorListVersion))
+	return func(specVersion, listVersion uint16) string {
+		return url + "?specversion=" + strconv.Itoa(int(specVersion)) + "&listversion=" + strconv.Itoa(int(listVersion))
 	}
 }
 
diff --git a/openrtb_ext/request_test.go b/openrtb_ext/request_test.go
index 94c042db..a05bae3a 100644
--- a/openrtb_ext/request_test.go
+++ b/openrtb_ext/request_test.go
@@ -268,15 +268,15 @@ func TestCloneExtRequestPrebid(t *testing.T) {
 				BidderConfigs: []BidderConfig{
 					{
 						Bidders: []string{"Bidder1", "bidder2"},
-						Config:  &Config{&ORTB2{Site: map[string]json.RawMessage{"test": json.RawMessage(`{}`)}}},
+						Config:  &Config{&ORTB2{Site: json.RawMessage(`{"value":"config1"}`)}},
 					},
 					{
 						Bidders: []string{"Bidder5", "bidder17"},
-						Config:  &Config{&ORTB2{Site: map[string]json.RawMessage{"test": json.RawMessage(`{"foo":"bar"}`), "more": json.RawMessage(`{}`)}}},
+						Config:  &Config{&ORTB2{App: json.RawMessage(`{"value":"config2"}`)}},
 					},
 					{
 						Bidders: []string{"foo"},
-						Config:  &Config{&ORTB2{User: map[string]json.RawMessage{"abc": json.RawMessage(`123`)}}},
+						Config:  &Config{&ORTB2{User: json.RawMessage(`{"value":"config3"}`)}},
 					},
 				},
 			},
@@ -284,15 +284,15 @@ func TestCloneExtRequestPrebid(t *testing.T) {
 				BidderConfigs: []BidderConfig{
 					{
 						Bidders: []string{"Bidder1", "bidder2"},
-						Config:  &Config{&ORTB2{Site: map[string]json.RawMessage{"test": json.RawMessage(`{}`)}}},
+						Config:  &Config{&ORTB2{Site: json.RawMessage(`{"value":"config1"}`)}},
 					},
 					{
 						Bidders: []string{"Bidder5", "bidder17"},
-						Config:  &Config{&ORTB2{Site: map[string]json.RawMessage{"test": json.RawMessage(`{"foo":"bar"}`), "more": json.RawMessage(`{}`)}}},
+						Config:  &Config{&ORTB2{App: json.RawMessage(`{"value":"config2"}`)}},
 					},
 					{
 						Bidders: []string{"foo"},
-						Config:  &Config{&ORTB2{User: map[string]json.RawMessage{"abc": json.RawMessage(`123`)}}},
+						Config:  &Config{&ORTB2{User: json.RawMessage(`{"value":"config3"}`)}},
 					},
 				},
 			},
@@ -302,7 +302,7 @@ func TestCloneExtRequestPrebid(t *testing.T) {
 					Bidders: []string{"george"},
 					Config:  &Config{nil},
 				}
-				prebid.BidderConfigs[2].Config.ORTB2.User["abc"] = json.RawMessage(`{"id": 345}`)
+				prebid.BidderConfigs[2].Config.ORTB2.User = json.RawMessage(`{"id": 345}`)
 				prebid.BidderConfigs = append(prebid.BidderConfigs, BidderConfig{
 					Bidders: []string{"bidder2"},
 					Config:  &Config{&ORTB2{}},