Repository: prebid/prebid-server
Pull request #1522 (2020-10-06 04:20:41)

Rework pubstack module tests to remove race conditions


Patch:
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 00000000..d8373fd4
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,18 @@
+# From https://github.com/microsoft/vscode-dev-containers/blob/master/containers/go/.devcontainer/Dockerfile
+ARG VARIANT=1
+FROM mcr.microsoft.com/vscode/devcontainers/go:${VARIANT}
+
+# [Optional] Install a version of Node.js using nvm for front end dev
+ARG INSTALL_NODE="true"
+ARG NODE_VERSION="lts/*"
+RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
+
+# [Optional] Uncomment this section to install additional OS packages.
+RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
+    && apt-get -y install --no-install-recommends vim
+
+# [Optional] Uncomment the next line to use go get to install anything else you need
+# RUN go get -x <your-dependency-or-tool>
+
+# [Optional] Uncomment this line to install global node packages.
+# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 00000000..b2c53776
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,45 @@
+// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at:
+// https://github.com/microsoft/vscode-dev-containers/tree/v0.112.0/containers/go
+{
+	"name": "Go",
+	"build": {
+		"dockerfile": "Dockerfile",
+		"args": {
+			// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
+			"VARIANT": "1.14",
+			// Options
+			"INSTALL_NODE": "false",
+			"NODE_VERSION": "lts/*",
+		}
+	},
+	"containerEnv": {
+		"GOPRIVATE": "${localEnv:GOPRIVATE}",
+	},
+	"runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ],
+
+	// Set *default* container specific settings.json values on container create.
+	"settings": {
+		"terminal.integrated.shell.linux": "/bin/bash",
+		"go.useGoProxyToCheckForToolUpdates": false,
+		"go.gopath": "/go",
+		//"go.toolsGopath": "/tmp/go",
+	},
+
+	// Add the IDs of extensions you want installed when the container is created.
+	"extensions": [
+		"golang.Go",
+		"ms-azuretools.vscode-docker",
+		"redhat.vscode-xml",
+		"redhat.vscode-yaml",
+		"eamodio.gitlens",
+	],
+
+	// Use 'forwardPorts' to make a list of ports inside the container available locally.
+	"forwardPorts": [8000,8001,6060],
+
+	// Use 'postCreateCommand' to run commands after the container is created.
+	"postCreateCommand": "mkdir ~/.ssh; ssh-keyscan github.com > ~/.ssh/known_hosts",
+
+	// Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
+	"remoteUser": "vscode"
+}
diff --git a/.gitignore b/.gitignore
index 60c24e79..79076f9b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,7 +8,6 @@ _obj
 _test
 .cover/
 .idea/
-.vscode/
 
 # Architecture specific extensions/prefixes
 *.[568vq]
@@ -46,6 +45,7 @@ analytics/filesystem/testFiles/
 # static/version.txt
 
 .idea/
+.vscode/
 
 # autogenerated mac file
 
diff --git a/README.md b/README.md
index 673c2b1b..5b90b0ee 100644
--- a/README.md
+++ b/README.md
@@ -55,3 +55,8 @@ Want to [add an adapter](https://docs.prebid.org/prebid-server/developers/add-ne
 Report bugs, request features, and suggest improvements [on Github](https://github.com/prebid/prebid-server/issues).
 
 Or better yet, [open a pull request](https://github.com/prebid/prebid-server/compare) with the changes you'd like to see.
+
+## IDE Setup for PBS-Go development
+
+The quickest way to start developing PBS-Go in a reproducible environment isolated from your host OS
+is by using this [VScode Remote Container Setup](devcontainer.md)
diff --git a/adapters/info.go b/adapters/info.go
index 982827c9..d94196d0 100644
--- a/adapters/info.go
+++ b/adapters/info.go
@@ -201,10 +201,11 @@ const (
 )
 
 type BidderInfo struct {
-	Status       BidderStatus      `yaml:"status" json:"status"`
-	Maintainer   *MaintainerInfo   `yaml:"maintainer" json:"maintainer"`
-	Capabilities *CapabilitiesInfo `yaml:"capabilities" json:"capabilities"`
-	AliasOf      string            `json:"aliasOf,omitempty"`
+	Status                  BidderStatus      `yaml:"status" json:"status"`
+	Maintainer              *MaintainerInfo   `yaml:"maintainer" json:"maintainer"`
+	Capabilities            *CapabilitiesInfo `yaml:"capabilities" json:"capabilities"`
+	AliasOf                 string            `json:"aliasOf,omitempty"`
+	ModifyingVastXmlAllowed bool              `yaml:"modifyingVastXmlAllowed" json:"-" xml:"-"`
 }
 
 type MaintainerInfo struct {
diff --git a/analytics/config/config.go b/analytics/config/config.go
index 7f7ded0f..fa63cb5a 100644
--- a/analytics/config/config.go
+++ b/analytics/config/config.go
@@ -69,3 +69,9 @@ func (ea enabledAnalytics) LogAmpObject(ao *analytics.AmpObject) {
 		module.LogAmpObject(ao)
 	}
 }
+
+func (ea enabledAnalytics) LogNotificationEventObject(ne *analytics.NotificationEvent) {
+	for _, module := range ea {
+		module.LogNotificationEventObject(ne)
+	}
+}
diff --git a/analytics/core.go b/analytics/core.go
index 737d1334..2cbf98d4 100644
--- a/analytics/core.go
+++ b/analytics/core.go
@@ -21,6 +21,7 @@ type PBSAnalyticsModule interface {
 	LogCookieSyncObject(*CookieSyncObject)
 	LogSetUIDObject(*SetUIDObject)
 	LogAmpObject(*AmpObject)
+	LogNotificationEventObject(*NotificationEvent)
 }
 
 //Loggable object of a transaction at /openrtb2/auction endpoint
@@ -67,3 +68,9 @@ type CookieSyncObject struct {
 	Errors       []error
 	BidderStatus []*usersync.CookieSyncBidders
 }
+
+// NotificationEvent is a loggable object
+type NotificationEvent struct {
+	Request *EventRequest   `json:"request"`
+	Account *config.Account `json:"account"`
+}
diff --git a/analytics/event.go b/analytics/event.go
new file mode 100644
index 00000000..5350223d
--- /dev/null
+++ b/analytics/event.go
@@ -0,0 +1,38 @@
+package analytics
+
+// EventType enumerates the values of events Prebid Server can receive for an ad.
+type EventType string
+
+// Possible values of events Prebid Server can receive for an ad.
+const (
+	Win EventType = "win"
+	Imp EventType = "imp"
+)
+
+// ResponseFormat enumerates the values of a Prebid Server event.
+type ResponseFormat string
+
+const (
+	// Blank describes an event which returns an HTTP 200 with an empty body.
+	Blank ResponseFormat = "b"
+	// Image describes an event which returns an HTTP 200 with a PNG body.
+	Image ResponseFormat = "i"
+)
+
+// Analytics indicates if the notification event should be handled or not
+type Analytics string
+
+const (
+	Enabled  Analytics = "1"
+	Disabled Analytics = "0"
+)
+
+type EventRequest struct {
+	Type      EventType      `json:"type,omitempty"`
+	Format    ResponseFormat `json:"format,omitempty"`
+	Analytics Analytics      `json:"analytics,omitempty"`
+	BidID     string         `json:"bidid,omitempty"`
+	AccountID string         `json:"account_id,omitempty"`
+	Bidder    string         `json:"bidder,omitempty"`
+	Timestamp int64          `json:"timestamp,omitempty"`
+}
diff --git a/analytics/filesystem/file_module.go b/analytics/filesystem/file_module.go
index 54d492ad..a0721d98 100644
--- a/analytics/filesystem/file_module.go
+++ b/analytics/filesystem/file_module.go
@@ -12,11 +12,12 @@ import (
 type RequestType string
 
 const (
-	COOKIE_SYNC RequestType = "/cookie_sync"
-	AUCTION     RequestType = "/openrtb2/auction"
-	VIDEO       RequestType = "/openrtb2/video"
-	SETUID      RequestType = "/set_uid"
-	AMP         RequestType = "/openrtb2/amp"
+	COOKIE_SYNC        RequestType = "/cookie_sync"
+	AUCTION            RequestType = "/openrtb2/auction"
+	VIDEO              RequestType = "/openrtb2/video"
+	SETUID             RequestType = "/set_uid"
+	AMP                RequestType = "/openrtb2/amp"
+	NOTIFICATION_EVENT RequestType = "/event"
 )
 
 //Module that can perform transactional logging
@@ -72,6 +73,18 @@ func (f *FileLogger) LogAmpObject(ao *analytics.AmpObject) {
 	f.Logger.Flush()
 }
 
+//Logs NotificationEvent to file
+func (f *FileLogger) LogNotificationEventObject(ne *analytics.NotificationEvent) {
+	if ne == nil {
+		return
+	}
+	//Code to parse the object and log in a way required
+	var b bytes.Buffer
+	b.WriteString(jsonifyNotificationEventObject(ne))
+	f.Logger.Debug(b.String())
+	f.Logger.Flush()
+}
+
 //Method to initialize the analytic module
 func NewFileLogger(filename string) (analytics.PBSAnalyticsModule, error) {
 	options := glog.LogOptions{
@@ -176,3 +189,20 @@ func jsonifyAmpObject(ao *analytics.AmpObject) string {
 		return fmt.Sprintf("Transactional Logs Error: Amp object badly formed %v", err)
 	}
 }
+
+func jsonifyNotificationEventObject(ne *analytics.NotificationEvent) string {
+	type alias analytics.NotificationEvent
+	b, err := json.Marshal(&struct {
+		Type RequestType `json:"type"`
+		*alias
+	}{
+		Type:  NOTIFICATION_EVENT,
+		alias: (*alias)(ne),
+	})
+
+	if err == nil {
+		return string(b)
+	} else {
+		return fmt.Sprintf("Transactional Logs Error: NotificationEvent object badly formed %v", err)
+	}
+}
diff --git a/analytics/pubstack/pubstack_module.go b/analytics/pubstack/pubstack_module.go
index 9f1a81c7..60ccde02 100644
--- a/analytics/pubstack/pubstack_module.go
+++ b/analytics/pubstack/pubstack_module.go
@@ -125,6 +125,9 @@ func (p *PubstackModule) LogAuctionObject(ao *analytics.AuctionObject) {
 	p.eventChannels[auction].Push(payload)
 }
 
+func (p *PubstackModule) LogNotificationEventObject(ne *analytics.NotificationEvent) {
+}
+
 func (p *PubstackModule) LogVideoObject(vo *analytics.VideoObject) {
 	p.muxConfig.RLock()
 	defer p.muxConfig.RUnlock()
diff --git a/config/accounts.go b/config/accounts.go
index 11c2e10e..162818eb 100644
--- a/config/accounts.go
+++ b/config/accounts.go
@@ -2,7 +2,8 @@ package config
 
 // Account represents a publisher account configuration
 type Account struct {
-	ID       string      `mapstructure:"id" json:"id"`
-	Disabled bool        `mapstructure:"disabled" json:"disabled"`
-	CacheTTL DefaultTTLs `mapstructure:"cache_ttl" json:"cache_ttl"`
+	ID            string      `mapstructure:"id" json:"id"`
+	Disabled      bool        `mapstructure:"disabled" json:"disabled"`
+	CacheTTL      DefaultTTLs `mapstructure:"cache_ttl" json:"cache_ttl"`
+	EventsEnabled bool        `mapstructure:"events_enabled" json:"events_enabled"`
 }
diff --git a/config/config.go b/config/config.go
index 832d67d8..7825a5f3 100755
--- a/config/config.go
+++ b/config/config.go
@@ -41,6 +41,8 @@ type Configuration struct {
 	StoredRequests    StoredRequests  `mapstructure:"stored_requests"`
 	StoredRequestsAMP StoredRequests  `mapstructure:"stored_amp_req"`
 	CategoryMapping   StoredRequests  `mapstructure:"category_mapping"`
+	VTrack            VTrack          `mapstructure:"vtrack"`
+	Event             Event           `mapstructure:"event"`
 	Accounts          StoredRequests  `mapstructure:"accounts"`
 	// Note that StoredVideo refers to stored video requests, and has nothing to do with caching video creatives.
 	StoredVideo StoredRequests `mapstructure:"stored_video_req"`
@@ -315,6 +317,16 @@ type PubstackBuffer struct {
 	Timeout    string `mapstructure:"timeout"`
 }
 
+type VTrack struct {
+	TimeoutMS          int64 `mapstructure:"timeout_ms"`
+	AllowUnknownBidder bool  `mapstructure:"allow_unknown_bidder"`
+	Enabled            bool  `mapstructure:"enabled"`
+}
+
+type Event struct {
+	TimeoutMS int64 `mapstructure:"timeout_ms"`
+}
+
 type HostCookie struct {
 	Domain             string `mapstructure:"domain"`
 	Family             string `mapstructure:"family"`
@@ -886,6 +898,12 @@ func SetupViper(v *viper.Viper, filename string) {
 	v.SetDefault("stored_video_req.http_events.refresh_rate_seconds", 0)
 	v.SetDefault("stored_video_req.http_events.timeout_ms", 0)
 
+	v.SetDefault("vtrack.timeout_ms", 2000)
+	v.SetDefault("vtrack.allow_unknown_bidder", true)
+	v.SetDefault("vtrack.enabled", true)
+
+	v.SetDefault("event.timeout_ms", 1000)
+
 	v.SetDefault("accounts.filesystem.enabled", false)
 	v.SetDefault("accounts.filesystem.directorypath", "./stored_requests/data/by_id")
 	v.SetDefault("accounts.in_memory_cache.type", "none")
diff --git a/devcontainer.md b/devcontainer.md
new file mode 100644
index 00000000..824356d8
--- /dev/null
+++ b/devcontainer.md
@@ -0,0 +1,67 @@
+
+### vscode in-container development
+
+The quickest way to get up and running with PBS-Go development in a reproducible environment isolated
+from your host OS is by loading the repository in a [Docker](https://docs.docker.com/get-docker/)
+container under [Visual Studio Code](https://code.visualstudio.com/).
+
+This covers installing Go and necessary IDE plugins for Golang editing and debugging, and is
+all automated via the VSCode [.devcontainer](.devcontainer/) configuration. See
+[VSCode Remote Containers](https://code.visualstudio.com/docs/remote/containers) for more
+details about how to customize.
+
+#### Setup
+
+Install:
+
+- [Docker](https://docs.docker.com/get-docker/)
+- [Visual Studio Code](https://code.visualstudio.com/)
+- [VSCode Remote Development Extension Pack](https://aka.ms/vscode-remote/download/extension)
+
+Then:
+
+- start VSCode and open repository
+- accept VSCode suggestion to _reopen in container_
+- VSCode will build a new container and install the IDE support and extensions in it
+
+Optionally, to use your github ssh key for accessing non-public GitHub repositories:
+- Method 1: add your github ssh key to agent. This is needed after each OS restart.
+```sh
+ssh-add ~/.ssh/id_rsa # or your ssh key for github
+```
+- Method 2: map your ~/.ssh (or just the key) as a docker volume in .devcontainer.json
+
+Feel free to customize .devcontainer.json if needed. You can add preset environment variables,
+other vscode extensions to preload and additional volume mounts.
+
+#### Starting PBS-Go
+
+- `Shift`-`Cmd`-`D` or `Run` icon brings up the `Launch prebid-server` panel with interactive
+debugger. Breakpoints can be set to stop execution.
+- CTRL-`\`` opens the terminal to start prebid-server non-interactively
+```sh
+go run main.go --alsologtostderr
+```
+- Create a pbs.yaml file if neccessary, with configuration overrides.
+
+#### Testing
+
+- Open any `*_test.go` file in editor
+- Individual test functions can be run directly by clicking the `run test`/`debug test` annotation
+above each test function.
+- At the top of the file you can see `run package tests | run file tests`
+- TIP: use `run package tests` at the top of the test file to quickly check code coverage:
+  the open editors for files in the tested package will have lines highlighted in green (covered)
+  and red (not covered)
+- CTRL-`\`` opens the terminal to run the test suite
+```sh
+./validate.sh
+```
+
+#### Editing
+- Style, imports are automatically updated on save.
+- Editor can suggest correct names and
+
+- Remote container commands popup by clicking on _Dev Container: Go_ at bottom left
+- `F1` -> type _rebuild container_ to restart with a fresh container
+- `F1` -> `^`-`\`` to toggle terminal
diff --git a/endpoints/events/event.go b/endpoints/events/event.go
new file mode 100644
index 00000000..fe178d8f
--- /dev/null
+++ b/endpoints/events/event.go
@@ -0,0 +1,338 @@
+package events
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"github.com/julienschmidt/httprouter"
+	accountService "github.com/prebid/prebid-server/account"
+	"github.com/prebid/prebid-server/analytics"
+	"github.com/prebid/prebid-server/config"
+	"github.com/prebid/prebid-server/errortypes"
+	"github.com/prebid/prebid-server/stored_requests"
+	"net/http"
+	"net/url"
+	"strconv"
+	"time"
+)
+
+const (
+	// Required
+	TemplateUrl        = "%v/event?t=%v&b=%v&a=%v"
+	TypeParameter      = "t"
+	BidIdParameter     = "b"
+	AccountIdParameter = "a"
+
+	// Optional
+	BidderParameter    = "bidder"
+	TimestampParameter = "ts"
+	FormatParameter    = "f"
+	AnalyticsParameter = "x"
+)
+
+var trackingPixelPng = &trackingPixel{
+	Content: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
+		0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
+		0x89, 0x00, 0x00, 0x00, 0x04, 0x73, 0x42, 0x49, 0x54, 0x08, 0x08, 0x08, 0x08, 0x7C, 0x08, 0x64, 0x88,
+		0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x08, 0x99, 0x63, 0x60, 0x60, 0x60, 0x60, 0x00, 0x00,
+		0x00, 0x05, 0x00, 0x01, 0x87, 0xA1, 0x4E, 0xD4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
+		0x42, 0x60, 0x82},
+	ContentType: "image/png",
+}
+
+type trackingPixel struct {
+	Content     []byte `json:"content,omitempty"`
+	ContentType string `json:"content_type,omitempty"`
+}
+
+type eventEndpoint struct {
+	Accounts      stored_requests.AccountFetcher
+	Analytics     analytics.PBSAnalyticsModule
+	Cfg           *config.Configuration
+	TrackingPixel *trackingPixel
+}
+
+func NewEventEndpoint(cfg *config.Configuration, accounts stored_requests.AccountFetcher, analytics analytics.PBSAnalyticsModule) httprouter.Handle {
+	ee := &eventEndpoint{
+		Accounts:      accounts,
+		Analytics:     analytics,
+		Cfg:           cfg,
+		TrackingPixel: trackingPixelPng,
+	}
+
+	return ee.Handle
+}
+
+func (e *eventEndpoint) Handle(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
+	// parse event request from http req
+	eventRequest, errs := ParseEventRequest(r)
+
+	// handle possible parsing errors
+	if len(errs) > 0 {
+		w.WriteHeader(http.StatusBadRequest)
+
+		for _, err := range errs {
+			w.Write([]byte(fmt.Sprintf("invalid request: %s\n", err.Error())))
+		}
+
+		return
+	}
+
+	// validate account id
+	accountId, err := checkRequiredParameter(r, AccountIdParameter)
+
+	if err != nil {
+		w.WriteHeader(http.StatusUnauthorized)
+		w.Write([]byte(fmt.Sprintf("Account '%s' is required query parameter and can't be empty", AccountIdParameter)))
+		return
+	}
+	eventRequest.AccountID = accountId
+
+	if eventRequest.Analytics != analytics.Enabled {
+		w.WriteHeader(http.StatusNoContent)
+		return
+	}
+
+	ctx := context.Background()
+	if e.Cfg.Event.TimeoutMS > 0 {
+		var cancel context.CancelFunc
+		ctx, cancel = context.WithTimeout(ctx, time.Duration(e.Cfg.Event.TimeoutMS)*time.Millisecond)
+		defer cancel()
+	}
+
+	// get account details
+	account, errs := accountService.GetAccount(ctx, e.Cfg, e.Accounts, eventRequest.AccountID)
+	if len(errs) > 0 {
+		status, messages := HandleAccountServiceErrors(errs)
+		w.WriteHeader(status)
+
+		for _, message := range messages {
+			w.Write([]byte(fmt.Sprintf("Invalid request: %s\n", message)))
+		}
+		return
+	}
+
+	// account does not support events
+	if !account.EventsEnabled {
+		w.WriteHeader(http.StatusUnauthorized)
+		w.Write([]byte(fmt.Sprintf("Account '%s' doesn't support events", eventRequest.AccountID)))
+		return
+	}
+
+	// handle notification event
+	e.Analytics.LogNotificationEventObject(&analytics.NotificationEvent{
+		Request: eventRequest,
+		Account: account,
+	})
+
+	// Add tracking pixel if format == image
+	if eventRequest.Format == analytics.Image {
+		w.WriteHeader(http.StatusOK)
+		w.Header().Add("Content-Type", e.TrackingPixel.ContentType)
+		w.Write(e.TrackingPixel.Content)
+
+		return
+	}
+
+	w.WriteHeader(http.StatusNoContent)
+}
+
+// EventRequestToUrl converts an analytics.EventRequest to an URL
+func EventRequestToUrl(externalUrl string, request *analytics.EventRequest) string {
+	s := fmt.Sprintf(TemplateUrl, externalUrl, request.Type, request.BidID, request.AccountID)
+
+	return s + optionalParameters(request)
+}
+
+// ParseEventRequest parses an analytics.EventRequest from an Http request
+func ParseEventRequest(r *http.Request) (*analytics.EventRequest, []error) {
+	event := &analytics.EventRequest{}
+	var errs []error
+	// validate type
+	if err := readType(event, r); err != nil {
+		errs = append(errs, err)
+	}
+
+	// validate bidid
+	if bidid, err := checkRequiredParameter(r, BidIdParameter); err != nil {
+		errs = append(errs, err)
+	} else {
+		event.BidID = bidid
+	}
+
+	// validate timestamp (optional)
+	if err := readTimestamp(event, r); err != nil {
+		errs = append(errs, err)
+	}
+
+	// validate format (optional)
+	if err := readFormat(event, r); err != nil {
+		errs = append(errs, err)
+	}
+
+	// validate analytics (optional)
+	if err := readAnalytics(event, r); err != nil {
+		errs = append(errs, err)
+	}
+
+	// Bidder
+	event.Bidder = r.URL.Query().Get(BidderParameter)
+
+	return event, errs
+}
+
+// HandleAccountServiceErrors handles account.GetAccount errors
+func HandleAccountServiceErrors(errs []error) (status int, messages []string) {
+	messages = []string{}
+	status = http.StatusBadRequest
+
+	for _, er := range errs {
+		if errors.Is(er, context.DeadlineExceeded) {
+			er = &errortypes.Timeout{
+				Message: er.Error(),
+			}
+		}
+
+		messages = append(messages, er.Error())
+
+		errCode := errortypes.ReadCode(er)
+
+		if errCode == errortypes.BlacklistedAppErrorCode || errCode == errortypes.BlacklistedAcctErrorCode {
+			status = http.StatusServiceUnavailable
+		}
+
+		if errCode == errortypes.TimeoutErrorCode && status == http.StatusBadRequest {
+			status = http.StatusGatewayTimeout
+		}
+	}
+
+	return status, messages
+}
+
+func optionalParameters(request *analytics.EventRequest) string {
+	r := url.Values{}
+
+	// timestamp
+	if request.Timestamp > 0 {
+		r.Add(TimestampParameter, strconv.FormatInt(request.Timestamp, 10))
+	}
+
+	// bidder
+	if request.Bidder != "" {
+		r.Add(BidderParameter, request.Bidder)
+	}
+
+	// format
+	switch request.Format {
+	case analytics.Blank:
+		r.Add(FormatParameter, string(analytics.Blank))
+	case analytics.Image:
+		r.Add(FormatParameter, string(analytics.Image))
+	}
+
+	//analytics
+	switch request.Analytics {
+	case analytics.Enabled:
+		r.Add(AnalyticsParameter, string(analytics.Enabled))
+	case analytics.Disabled:
+		r.Add(AnalyticsParameter, string(analytics.Disabled))
+	}
+
+	opt := r.Encode()
+
+	if opt != "" {
+		return "&" + opt
+	}
+
+	return opt
+}
+
+// readType validates analytics.EventRequest type
+func readType(er *analytics.EventRequest, httpRequest *http.Request) error {
+	t, err := checkRequiredParameter(httpRequest, TypeParameter)
+
+	if err != nil {
+		return err
+	}
+
+	switch t {
+	case string(analytics.Imp):
+		er.Type = analytics.Imp
+		return nil
+	case string(analytics.Win):
+		er.Type = analytics.Win
+		return nil
+	default:
+		return &errortypes.BadInput{Message: fmt.Sprintf("unknown type: '%s'", t)}
+	}
+}
+
+// readFormat validates analytics.EventRequest format attribute
+func readFormat(er *analytics.EventRequest, httpRequest *http.Request) error {
+	f := httpRequest.URL.Query().Get(FormatParameter)
+
+	if f != "" {
+		switch f {
+		case string(analytics.Blank):
+			er.Format = analytics.Blank
+			return nil
+		case string(analytics.Image):
+			er.Format = analytics.Image
+			return nil
+		default:
+			return &errortypes.BadInput{Message: fmt.Sprintf("unknown format: '%s'", f)}
+		}
+	}
+
+	return nil
+}
+
+// readAnalytics validates analytics.EventRequest analytics attribute
+func readAnalytics(er *analytics.EventRequest, httpRequest *http.Request) error {
+	a := httpRequest.URL.Query().Get(AnalyticsParameter)
+
+	if a != "" {
+		switch a {
+		case string(analytics.Enabled):
+			er.Analytics = analytics.Enabled
+			return nil
+		case string(analytics.Disabled):
+			er.Analytics = analytics.Disabled
+			return nil
+		default:
+			return &errortypes.BadInput{Message: fmt.Sprintf("unknown analytics: '%s'", a)}
+		}
+	}
+
+	er.Analytics = analytics.Enabled
+	return nil
+}
+
+// readTimestamp validates analytics.EventRequest timestamp attribute
+func readTimestamp(er *analytics.EventRequest, httpRequest *http.Request) error {
+	t := httpRequest.URL.Query().Get(TimestampParameter)
+
+	if t != "" {
+		ts, err := strconv.ParseInt(t, 10, 64)
+
+		if err != nil {
+			return &errortypes.BadInput{Message: fmt.Sprintf("invalid request: error parsing timestamp '%s'", t)}
+		}
+
+		er.Timestamp = ts
+		return nil
+	}
+
+	return nil
+}
+
+// checkRequiredParameter checks if http.Request contains all required parameters
+func checkRequiredParameter(httpRequest *http.Request, parameter string) (string, error) {
+	t := httpRequest.URL.Query().Get(parameter)
+
+	if t == "" {
+		return "", &errortypes.BadInput{Message: fmt.Sprintf("parameter '%s' is required", parameter)}
+	}
+
+	return t, nil
+}
diff --git a/endpoints/events/vtrack.go b/endpoints/events/vtrack.go
new file mode 100644
index 00000000..c4051554
--- /dev/null
+++ b/endpoints/events/vtrack.go
@@ -0,0 +1,300 @@
+package events
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"github.com/golang/glog"
+	"github.com/julienschmidt/httprouter"
+	accountService "github.com/prebid/prebid-server/account"
+	"github.com/prebid/prebid-server/adapters"
+	"github.com/prebid/prebid-server/analytics"
+	"github.com/prebid/prebid-server/config"
+	"github.com/prebid/prebid-server/errortypes"
+	"github.com/prebid/prebid-server/prebid_cache_client"
+	"github.com/prebid/prebid-server/stored_requests"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"sort"
+	"strings"
+	"time"
+)
+
+const (
+	AccountParameter   = "a"
+	ImpressionCloseTag = "</Impression>"
+	ImpressionOpenTag  = "<Impression>"
+)
+
+type vtrackEndpoint struct {
+	Cfg         *config.Configuration
+	Accounts    stored_requests.AccountFetcher
+	BidderInfos adapters.BidderInfos
+	Cache       prebid_cache_client.Client
+}
+
+type BidCacheRequest struct {
+	Puts []prebid_cache_client.Cacheable `json:"puts"`
+}
+
+type BidCacheResponse struct {
+	Responses []CacheObject `json:"responses"`
+}
+
+type CacheObject struct {
+	UUID string `json:"uuid"`
+}
+
+func NewVTrackEndpoint(cfg *config.Configuration, accounts stored_requests.AccountFetcher, cache prebid_cache_client.Client, bidderInfos adapters.BidderInfos) httprouter.Handle {
+	vte := &vtrackEndpoint{
+		Cfg:         cfg,
+		Accounts:    accounts,
+		BidderInfos: bidderInfos,
+		Cache:       cache,
+	}
+
+	return vte.Handle
+}
+
+// /vtrack Handler
+func (v *vtrackEndpoint) Handle(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
+
+	// get account id from request parameter
+	accountId := getAccountId(r)
+
+	// account id is required
+	if accountId == "" {
+		w.WriteHeader(http.StatusBadRequest)
+		w.Write([]byte(fmt.Sprintf("Account '%s' is required query parameter and can't be empty", AccountParameter)))
+		return
+	}
+
+	// parse puts request from request body
+	req, err := ParseVTrackRequest(r, v.Cfg.MaxRequestSize+1)
+
+	// check if there was any error while parsing puts request
+	if err != nil {
+		w.WriteHeader(http.StatusBadRequest)
+		w.Write([]byte(fmt.Sprintf("Invalid request: %s\n", err.Error())))
+		return
+	}
+
+	ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Duration(v.Cfg.VTrack.TimeoutMS)*time.Millisecond))
+	defer cancel()
+
+	// get account details
+	account, errs := accountService.GetAccount(ctx, v.Cfg, v.Accounts, accountId)
+	if len(errs) > 0 {
+		status, messages := HandleAccountServiceErrors(errs)
+		w.WriteHeader(status)
+
+		for _, message := range messages {
+			w.Write([]byte(fmt.Sprintf("Invalid request: %s\n", message)))
+		}
+		return
+	}
+
+	// insert impression tracking if account allows events and bidder allows VAST modification
+	if v.Cache != nil {
+		cachingResponse, errs := v.handleVTrackRequest(ctx, req, account)
+
+		if len(errs) > 0 {
+			w.WriteHeader(http.StatusInternalServerError)
+			for _, err := range errs {
+				w.Write([]byte(fmt.Sprintf("Error(s) updating vast: %s\n", err.Error())))
+
+				return
+			}
+		}
+
+		d, err := json.Marshal(*cachingResponse)
+
+		if err != nil {
+			w.WriteHeader(http.StatusInternalServerError)
+			w.Write([]byte(fmt.Sprintf("Error serializing pbs cache response: %s\n", err.Error())))
+
+			return
+		}
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		w.Write(d)
+
+		return
+	}
+
+	w.WriteHeader(http.StatusInternalServerError)
+	w.Write([]byte("PBS Cache client is not configured"))
+}
+
+// GetVastUrlTracking creates a vast url tracking
+func GetVastUrlTracking(externalUrl string, bidid string, bidder string, accountId string, timestamp int64) string {
+
+	eventReq := &analytics.EventRequest{
+		Type:      analytics.Imp,
+		BidID:     bidid,
+		AccountID: accountId,
+		Bidder:    bidder,
+		Timestamp: timestamp,
+		Format:    analytics.Blank,
+	}
+
+	return EventRequestToUrl(externalUrl, eventReq)
+}
+
+// ParseVTrackRequest parses a BidCacheRequest from an HTTP Request
+func ParseVTrackRequest(httpRequest *http.Request, maxRequestSize int64) (req *BidCacheRequest, err error) {
+	req = &BidCacheRequest{}
+	err = nil
+
+	// Pull the request body into a buffer, so we have it for later usage.
+	lr := &io.LimitedReader{
+		R: httpRequest.Body,
+		N: maxRequestSize,
+	}
+
+	defer httpRequest.Body.Close()
+	requestJson, err := ioutil.ReadAll(lr)
+	if err != nil {
+		return req, err
+	}
+
+	// Check if the request size was too large
+	if lr.N <= 0 {
+		err = &errortypes.BadInput{Message: fmt.Sprintf("request size exceeded max size of %d bytes", maxRequestSize-1)}
+		return req, err
+	}
+
+	if len(requestJson) == 0 {
+		err = &errortypes.BadInput{Message: "request body is empty"}
+		return req, err
+	}
+
+	if err := json.Unmarshal(requestJson, req); err != nil {
+		return req, err
+	}
+
+	for _, bcr := range req.Puts {
+		if bcr.BidID == "" {
+			err = error(&errortypes.BadInput{Message: fmt.Sprint("'bidid' is required field and can't be empty")})
+			return req, err
+		}
+
+		if bcr.Bidder == "" {
+			err = error(&errortypes.BadInput{Message: fmt.Sprint("'bidder' is required field and can't be empty")})
+			return req, err
+		}
+	}
+
+	return req, nil
+}
+
+// handleVTrackRequest handles a VTrack request
+func (v *vtrackEndpoint) handleVTrackRequest(ctx context.Context, req *BidCacheRequest, account *config.Account) (*BidCacheResponse, []error) {
+	biddersAllowingVastUpdate := getBiddersAllowingVastUpdate(req, &v.BidderInfos, v.Cfg.VTrack.AllowUnknownBidder)
+	// cache data
+	r, errs := v.cachePutObjects(ctx, req, biddersAllowingVastUpdate, account.ID)
+
+	// handle pbs caching errors
+	if len(errs) != 0 {
+		glog.Errorf("Error(s) updating vast: %v", errs)
+		return nil, errs
+	}
+
+	// build response
+	response := &BidCacheResponse{
+		Responses: []CacheObject{},
+	}
+
+	for _, uuid := range r {
+		response.Responses = append(response.Responses, CacheObject{
+			UUID: uuid,
+		})
+	}
+
+	return response, nil
+}
+
+// cachePutObjects caches BidCacheRequest data
+func (v *vtrackEndpoint) cachePutObjects(ctx context.Context, req *BidCacheRequest, biddersAllowingVastUpdate map[string]struct{}, accountId string) ([]string, []error) {
+	var cacheables []prebid_cache_client.Cacheable
+
+	for _, c := range req.Puts {
+
+		nc := &prebid_cache_client.Cacheable{
+			Type:       c.Type,
+			Data:       c.Data,
+			TTLSeconds: c.TTLSeconds,
+			Key:        c.Key,
+		}
+
+		if _, ok := biddersAllowingVastUpdate[c.Bidder]; ok && nc.Data != nil {
+			nc.Data = modifyVastXml(v.Cfg.ExternalURL, nc.Data, c.BidID, c.Bidder, accountId, c.Timestamp)
+		}
+
+		cacheables = append(cacheables, *nc)
+	}
+
+	return v.Cache.PutJson(ctx, cacheables)
+}
+
+// getBiddersAllowingVastUpdate returns a list of bidders that allow VAST XML modification
+func getBiddersAllowingVastUpdate(req *BidCacheRequest, bidderInfos *adapters.BidderInfos, allowUnknownBidder bool) map[string]struct{} {
+	bl := map[string]struct{}{}
+
+	for _, bcr := range req.Puts {
+		if _, ok := bl[bcr.Bidder]; isAllowVastForBidder(bcr.Bidder, bidderInfos, allowUnknownBidder) && !ok {
+			bl[bcr.Bidder] = struct{}{}
+		}
+	}
+
+	return bl
+}
+
+// isAllowVastForBidder checks if a bidder is active and allowed to modify vast xml data
+func isAllowVastForBidder(bidder string, bidderInfos *adapters.BidderInfos, allowUnknownBidder bool) bool {
+	//if bidder is active and isModifyingVastXmlAllowed is true
+	// check if bidder is configured
+	if b, ok := (*bidderInfos)[bidder]; bidderInfos != nil && ok {
+		// check if bidder is enabled
+		return b.Status == adapters.StatusActive && b.ModifyingVastXmlAllowed
+	}
+
+	return allowUnknownBidder
+}
+
+// getAccountId extracts an account id from an HTTP Request
+func getAccountId(httpRequest *http.Request) string {
+	return httpRequest.URL.Query().Get(AccountParameter)
+}
+
+// modifyVastXml modifies BidCacheRequest element Vast XML data
+func modifyVastXml(externalUrl string, data json.RawMessage, bidid string, bidder string, accountId string, timestamp int64) json.RawMessage {
+	c := string(data)
+	ci := strings.Index(c, ImpressionCloseTag)
+
+	// no impression tag - pass it as it is
+	if ci == -1 {
+		return data
+	}
+
+	vastUrlTracking := GetVastUrlTracking(externalUrl, bidid, bidder, accountId, timestamp)
+	impressionUrl := "<![CDATA[" + vastUrlTracking + "]]>"
+	oi := strings.Index(c, ImpressionOpenTag)
+
+	if ci-oi == len(ImpressionOpenTag) {
+		return json.RawMessage(strings.Replace(c, ImpressionOpenTag, ImpressionOpenTag+impressionUrl, 1))
+	}
+
+	return json.RawMessage(strings.Replace(c, ImpressionCloseTag, ImpressionCloseTag+ImpressionOpenTag+impressionUrl+ImpressionCloseTag, 1))
+}
+
+func contains(s []string, e string) bool {
+	if len(s) == 0 {
+		return false
+	}
+
+	i := sort.SearchStrings(s, e)
+	return i < len(s) && s[i] == e
+}
diff --git a/endpoints/openrtb2/amp_auction.go b/endpoints/openrtb2/amp_auction.go
index d7442f5e..5536c4e6 100644
--- a/endpoints/openrtb2/amp_auction.go
+++ b/endpoints/openrtb2/amp_auction.go
@@ -144,6 +144,8 @@ func (deps *endpointDeps) AmpAuction(w http.ResponseWriter, r *http.Request, _ h
 		return
 	}
 
+	ao.Request = req
+
 	ctx := context.Background()
 	var cancel context.CancelFunc
 	if req.TMax > 0 {
diff --git a/prebid_cache_client/client.go b/prebid_cache_client/client.go
index 1ad78830..43dfbc9a 100644
--- a/prebid_cache_client/client.go
+++ b/prebid_cache_client/client.go
@@ -41,10 +41,14 @@ const (
 )
 
 type Cacheable struct {
-	Type       PayloadType
-	Data       json.RawMessage
-	TTLSeconds int64
-	Key        string
+	Type       PayloadType     `json:"type,omitempty"`
+	Data       json.RawMessage `json:"value,omitempty"`
+	TTLSeconds int64           `json:"ttlseconds,omitempty"`
+	Key        string          `json:"key,omitempty"`
+
+	BidID     string `json:"bidid,omitempty"`     // this is "/vtrack" specific
+	Bidder    string `json:"bidder,omitempty"`    // this is "/vtrack" specific
+	Timestamp int64  `json:"timestamp,omitempty"` // this is "/vtrack" specific
 }
 
 func NewClient(httpClient *http.Client, conf *config.Cache, extCache *config.ExternalCache, metrics pbsmetrics.MetricsEngine) Client {
@@ -181,6 +185,25 @@ func encodeValueToBuffer(value Cacheable, leadingComma bool, buffer *bytes.Buffe
 		buffer.WriteString(string(value.Key))
 		buffer.WriteString(`"`)
 	}
+
+	//vtrack specific
+	if len(value.BidID) > 0 {
+		buffer.WriteString(`,"bidid":"`)
+		buffer.WriteString(string(value.BidID))
+		buffer.WriteString(`"`)
+	}
+
+	if len(value.Bidder) > 0 {
+		buffer.WriteString(`,"bidder":"`)
+		buffer.WriteString(string(value.Bidder))
+		buffer.WriteString(`"`)
+	}
+
+	if value.Timestamp > 0 {
+		buffer.WriteString(`,"timestamp":`)
+		buffer.WriteString(strconv.FormatInt(value.Timestamp, 10))
+	}
+
 	buffer.WriteByte('}')
 	return nil
 }
diff --git a/router/router.go b/router/router.go
index 4826f0e5..919781f7 100644
--- a/router/router.go
+++ b/router/router.go
@@ -6,6 +6,7 @@ import (
 	"database/sql"
 	"encoding/json"
 	"fmt"
+	"github.com/prebid/prebid-server/endpoints/events"
 	"io/ioutil"
 	"net/http"
 	"path/filepath"
@@ -277,6 +278,16 @@ func New(cfg *config.Configuration, rateConvertor *currencies.RateConverter) (r
 	r.GET("/", serveIndex)
 	r.ServeFiles("/static/*filepath", http.Dir("static"))
 
+	// vtrack endpoint
+	if cfg.VTrack.Enabled {
+		vtrackEndpoint := events.NewVTrackEndpoint(cfg, accounts, cacheClient, bidderInfos)
+		r.POST("/vtrack", vtrackEndpoint)
+	}
+
+	// event endpoint
+	eventEndpoint := events.NewEventEndpoint(cfg, accounts, pbsAnalytics)
+	r.GET("/event", eventEndpoint)
+
 	userSyncDeps := &pbs.UserSyncDeps{
 		HostCookieConfig: &(cfg.HostCookie),
 		ExternalUrl:      cfg.ExternalURL,


Test patch:
diff --git a/analytics/config/config_test.go b/analytics/config/config_test.go
index 583d475e..52edc79c 100644
--- a/analytics/config/config_test.go
+++ b/analytics/config/config_test.go
@@ -51,6 +51,11 @@ func TestSampleModule(t *testing.T) {
 	if count != 5 {
 		t.Errorf("PBSAnalyticsModule failed at LogVideoObject")
 	}
+
+	am.LogNotificationEventObject(&analytics.NotificationEvent{})
+	if count != 6 {
+		t.Errorf("PBSAnalyticsModule failed at LogNotificationEventObject")
+	}
 }
 
 type sampleModule struct {
@@ -67,6 +72,8 @@ func (m *sampleModule) LogSetUIDObject(so *analytics.SetUIDObject) { *m.count++
 
 func (m *sampleModule) LogAmpObject(ao *analytics.AmpObject) { *m.count++ }
 
+func (m *sampleModule) LogNotificationEventObject(ne *analytics.NotificationEvent) { *m.count++ }
+
 func initAnalytics(count *int) analytics.PBSAnalyticsModule {
 	modules := make(enabledAnalytics, 0)
 	modules = append(modules, &sampleModule{count})
diff --git a/analytics/filesystem/file_module_test.go b/analytics/filesystem/file_module_test.go
index 6e2d9f62..cfc923c7 100644
--- a/analytics/filesystem/file_module_test.go
+++ b/analytics/filesystem/file_module_test.go
@@ -1,6 +1,7 @@
 package filesystem
 
 import (
+	"github.com/prebid/prebid-server/config"
 	"net/http"
 	"os"
 	"strings"
@@ -64,6 +65,20 @@ func TestCookieSyncObject_ToJson(t *testing.T) {
 	}
 }
 
+func TestLogNotificationEventObject_ToJson(t *testing.T) {
+	neo := &analytics.NotificationEvent{
+		Request: &analytics.EventRequest{
+			Bidder: "bidder",
+		},
+		Account: &config.Account{
+			ID: "id",
+		},
+	}
+	if neoJson := jsonifyNotificationEventObject(neo); strings.Contains(neoJson, "Transactional Logs Error") {
+		t.Fatalf("NotificationEventObject failed to convert to json")
+	}
+}
+
 func TestFileLogger_LogObjects(t *testing.T) {
 	if _, err := os.Stat(TEST_DIR); os.IsNotExist(err) {
 		if err = os.MkdirAll(TEST_DIR, 0755); err != nil {
@@ -77,6 +92,7 @@ func TestFileLogger_LogObjects(t *testing.T) {
 		fl.LogAmpObject(&analytics.AmpObject{})
 		fl.LogSetUIDObject(&analytics.SetUIDObject{})
 		fl.LogCookieSyncObject(&analytics.CookieSyncObject{})
+		fl.LogNotificationEventObject(&analytics.NotificationEvent{})
 	} else {
 		t.Fatalf("Couldn't initialize file logger: %v", err)
 	}
diff --git a/analytics/pubstack/pubstack_module_test.go b/analytics/pubstack/pubstack_module_test.go
index 8d4dfdd6..6bebc937 100644
--- a/analytics/pubstack/pubstack_module_test.go
+++ b/analytics/pubstack/pubstack_module_test.go
@@ -2,11 +2,9 @@ package pubstack
 
 import (
 	"encoding/json"
-	"github.com/prebid/prebid-server/analytics/pubstack/eventchannel"
 	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
-	"net/url"
 	"os"
 	"testing"
 	"time"
@@ -16,7 +14,7 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-func loadJsonFromFile() (*analytics.AuctionObject, error) {
+func loadJSONFromFile() (*analytics.AuctionObject, error) {
 	req, err := os.Open("mocks/mock_openrtb_request.json")
 	if err != nil {
 		return nil, err
@@ -57,130 +55,171 @@ func loadJsonFromFile() (*analytics.AuctionObject, error) {
 	}, nil
 }
 
-func TestPubstackModule(t *testing.T) {
-
-	remoteConfig := &Configuration{}
-	server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
-		defer req.Body.Close()
-		data, _ := json.Marshal(remoteConfig)
-		res.Write(data)
-	}))
-	client := server.Client()
-
-	defer server.Close()
-
-	// Loading Issues
-	_, err := NewPubstackModule(client, "scope", server.URL, "1z", 100, "90MB", "15m")
-	assert.NotNil(t, err) // should raise an error since  we can't parse args // configRefreshDelay
-
-	_, err = NewPubstackModule(client, "scope", server.URL, "1h", 100, "90z", "15m")
-	assert.NotNil(t, err) // should raise an error since  we can't parse args // maxByte
-
-	_, err = NewPubstackModule(client, "scope", server.URL, "1h", 100, "90MB", "15z")
-	assert.NotNil(t, err) // should raise an error since  we can't parse args // maxTime
-
-	// Loading OK
-	module, err := NewPubstackModule(client, "scope", server.URL, "10ms", 100, "90MB", "15m")
-	assert.Nil(t, err)
-
-	// Default Configuration
-	pubstack, ok := module.(*PubstackModule)
-	assert.Equal(t, ok, true) //PBSAnalyticsModule is also a PubstackModule
-	assert.Equal(t, len(pubstack.cfg.Features), 5)
-	assert.Equal(t, pubstack.cfg.Features[auction], false)
-	assert.Equal(t, pubstack.cfg.Features[video], false)
-	assert.Equal(t, pubstack.cfg.Features[amp], false)
-	assert.Equal(t, pubstack.cfg.Features[setUID], false)
-	assert.Equal(t, pubstack.cfg.Features[cookieSync], false)
-
-	assert.Equal(t, len(pubstack.eventChannels), 0)
-
-	// Process Auction Event
-	counter := 0
-	send := func(_ []byte) error {
-		counter++
-		return nil
+func TestPubstackModuleErrors(t *testing.T) {
+	tests := []struct {
+		description  string
+		refreshDelay string
+		maxByteSize  string
+		maxTime      string
+	}{
+		{
+			description:  "refresh delay is in an invalid format",
+			refreshDelay: "1invalid",
+			maxByteSize:  "90MB",
+			maxTime:      "15m",
+		},
+		{
+			description:  "max byte size is in an invalid format",
+			refreshDelay: "1h",
+			maxByteSize:  "90invalid",
+			maxTime:      "15m",
+		},
+		{
+			description:  "max time is in an invalid format",
+			refreshDelay: "1h",
+			maxByteSize:  "90MB",
+			maxTime:      "15invalid",
+		},
 	}
-	mockedEvent, err := loadJsonFromFile()
-	if err != nil {
-		t.Fail()
+
+	for _, tt := range tests {
+		_, err := NewPubstackModule(&http.Client{}, "scope", "http://example.com", tt.refreshDelay, 100, tt.maxByteSize, tt.maxTime)
+		assert.NotNil(t, err, tt.description)
 	}
+}
 
-	pubstack.eventChannels[auction] = eventchannel.NewEventChannel(send, 2000, 1, 10*time.Second)
-	pubstack.eventChannels[video] = eventchannel.NewEventChannel(send, 2000, 1, 10*time.Second)
-	pubstack.eventChannels[amp] = eventchannel.NewEventChannel(send, 2000, 1, 10*time.Second)
-	pubstack.eventChannels[setUID] = eventchannel.NewEventChannel(send, 2000, 1, 10*time.Second)
-	pubstack.eventChannels[cookieSync] = eventchannel.NewEventChannel(send, 2000, 1, 10*time.Second)
-
-	pubstack.LogAuctionObject(mockedEvent)
-	pubstack.LogAmpObject(&analytics.AmpObject{
-		Status: http.StatusOK,
-	})
-	pubstack.LogCookieSyncObject(&analytics.CookieSyncObject{
-		Status: http.StatusOK,
-	})
-	pubstack.LogVideoObject(&analytics.VideoObject{
-		Status: http.StatusOK,
-	})
-	pubstack.LogSetUIDObject(&analytics.SetUIDObject{
-		Status: http.StatusOK,
-	})
-
-	pubstack.closeAllEventChannels()
-	time.Sleep(10 * time.Millisecond) // process channel
-	assert.Equal(t, counter, 0)
-
-	// Hot-Reload config
-	newFeatures := make(map[string]bool)
-	newFeatures[auction] = true
-	newFeatures[video] = true
-	newFeatures[amp] = true
-	newFeatures[cookieSync] = true
-	newFeatures[setUID] = true
-
-	remoteConfig = &Configuration{
-		ScopeID:  "new-scope",
-		Endpoint: "new-endpoint",
-		Features: newFeatures,
+func TestPubstackModuleSuccess(t *testing.T) {
+	tests := []struct {
+		description string
+		feature     string
+		logObject   func(analytics.PBSAnalyticsModule)
+	}{
+		{
+			description: "auction events are only published when logging an auction object with auction feature on",
+			feature:     auction,
+			logObject: func(module analytics.PBSAnalyticsModule) {
+				module.LogAuctionObject(&analytics.AuctionObject{Status: http.StatusOK})
+			},
+		},
+		{
+			description: "AMP events are only published when logging an AMP object with AMP feature on",
+			feature:     amp,
+			logObject: func(module analytics.PBSAnalyticsModule) {
+				module.LogAmpObject(&analytics.AmpObject{Status: http.StatusOK})
+			},
+		},
+		{
+			description: "video events are only published when logging a video object with video feature on",
+			feature:     video,
+			logObject: func(module analytics.PBSAnalyticsModule) {
+				module.LogVideoObject(&analytics.VideoObject{Status: http.StatusOK})
+			},
+		},
+		{
+			description: "cookie events are only published when logging a cookie object with cookie feature on",
+			feature:     cookieSync,
+			logObject: func(module analytics.PBSAnalyticsModule) {
+				module.LogCookieSyncObject(&analytics.CookieSyncObject{Status: http.StatusOK})
+			},
+		},
+		{
+			description: "setUID events are only published when logging a setUID object with setUID feature on",
+			feature:     setUID,
+			logObject: func(module analytics.PBSAnalyticsModule) {
+				module.LogSetUIDObject(&analytics.SetUIDObject{Status: http.StatusOK})
+			},
+		},
 	}
 
-	endpoint, _ := url.Parse(server.URL)
-	pubstack.reloadConfig(endpoint)
-
-	time.Sleep(2 * time.Millisecond) // process channel
-	assert.Equal(t, len(pubstack.cfg.Features), 5)
-	assert.Equal(t, pubstack.cfg.Features[auction], true)
-	assert.Equal(t, pubstack.cfg.Features[video], true)
-	assert.Equal(t, pubstack.cfg.Features[amp], true)
-	assert.Equal(t, pubstack.cfg.Features[setUID], true)
-	assert.Equal(t, pubstack.cfg.Features[cookieSync], true)
-	assert.Equal(t, pubstack.cfg.ScopeID, "new-scope")
-	assert.Equal(t, pubstack.cfg.Endpoint, "new-endpoint")
-	assert.Equal(t, len(pubstack.eventChannels), 5)
-
-	counter = 0
-	pubstack.eventChannels[auction] = eventchannel.NewEventChannel(send, 2000, 1, 10*time.Second)
-	pubstack.eventChannels[video] = eventchannel.NewEventChannel(send, 2000, 1, 10*time.Second)
-	pubstack.eventChannels[amp] = eventchannel.NewEventChannel(send, 2000, 1, 10*time.Second)
-	pubstack.eventChannels[setUID] = eventchannel.NewEventChannel(send, 2000, 1, 10*time.Second)
-	pubstack.eventChannels[cookieSync] = eventchannel.NewEventChannel(send, 2000, 1, 10*time.Second)
-
-	pubstack.LogAuctionObject(mockedEvent)
-	pubstack.LogAmpObject(&analytics.AmpObject{
-		Status: http.StatusOK,
-	})
-	pubstack.LogCookieSyncObject(&analytics.CookieSyncObject{
-		Status: http.StatusOK,
-	})
-	pubstack.LogVideoObject(&analytics.VideoObject{
-		Status: http.StatusOK,
-	})
-	pubstack.LogSetUIDObject(&analytics.SetUIDObject{
-		Status: http.StatusOK,
-	})
-	pubstack.closeAllEventChannels()
-	time.Sleep(10 * time.Millisecond)
-
-	assert.Equal(t, counter, 5)
+	for _, tt := range tests {
+		// original config is loaded when the module is created
+		// the feature is disabled so no events should be sent
+		origConfig := &Configuration{
+			Features: map[string]bool{
+				tt.feature: false,
+			},
+		}
+		// updated config is hot-reloaded after some time passes
+		// the feature is enabled so events should be sent
+		updatedConfig := &Configuration{
+			Features: map[string]bool{
+				tt.feature: true,
+			},
+		}
+
+		// create server with root endpoint that returns the current config
+		// add an intake endpoint that PBS hits when events are sent
+		rootCount := 0
+		mux := http.NewServeMux()
+		mux.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
+			rootCount++
+			defer req.Body.Close()
+
+			if rootCount > 1 {
+				if data, err := json.Marshal(updatedConfig); err != nil {
+					res.WriteHeader(http.StatusBadRequest)
+				} else {
+					res.Write(data)
+				}
+			} else {
+				if data, err := json.Marshal(origConfig); err != nil {
+					res.WriteHeader(http.StatusBadRequest)
+				} else {
+					res.Write(data)
+				}
+			}
+		})
+
+		intakeChannel := make(chan int) // using a channel rather than examining the count directly to avoid race
+		mux.HandleFunc("/intake/"+tt.feature+"/", func(res http.ResponseWriter, req *http.Request) {
+			intakeChannel <- 1
+		})
+		server := httptest.NewServer(mux)
+		client := server.Client()
+
+		// set the server url on each of the configs
+		origConfig.Endpoint = server.URL
+		updatedConfig.Endpoint = server.URL
+
+		// instantiate module with 25ms config refresh rate
+		module, err := NewPubstackModule(client, "scope", server.URL, "15ms", 100, "1B", "10ms")
+		assert.Nil(t, err, tt.description)
+
+		// allow time for the module to load the original config
+		time.Sleep(10 * time.Millisecond)
+
+		pubstack, _ := module.(*PubstackModule)
+		// attempt to log but no event channel was created because the feature is disabled in the original config
+		tt.logObject(pubstack)
+
+		// verify no event was received over a 10ms period
+		assertChanNone(t, intakeChannel, tt.description)
+
+		// allow time for the server to start serving the updated config
+		time.Sleep(10 * time.Millisecond)
+
+		// attempt to log; the event channel should have been created because the feature is enabled in updated config
+		tt.logObject(pubstack)
+
+		// verify an event was received within 10ms
+		assertChanOne(t, intakeChannel, tt.description)
+	}
+}
 
+func assertChanNone(t *testing.T, c <-chan int, msgAndArgs ...interface{}) bool {
+	select {
+	case <-c:
+		return assert.Fail(t, "Should NOT receive an event, but did", msgAndArgs...)
+	case <-time.After(10 * time.Millisecond):
+		return true
+	}
+}
+
+func assertChanOne(t *testing.T, c <-chan int, msgAndArgs ...interface{}) bool {
+	select {
+	case <-c:
+		return true
+	case <-time.After(10 * time.Millisecond):
+		return assert.Fail(t, "Should receive an event, but did NOT", msgAndArgs...)
+	}
 }
diff --git a/endpoints/events/account_test.go b/endpoints/events/account_test.go
new file mode 100644
index 00000000..ff0dd51b
--- /dev/null
+++ b/endpoints/events/account_test.go
@@ -0,0 +1,161 @@
+package events
+
+import (
+	"errors"
+	"fmt"
+	"github.com/julienschmidt/httprouter"
+	"github.com/prebid/prebid-server/adapters"
+	"github.com/prebid/prebid-server/config"
+	"github.com/prebid/prebid-server/stored_requests"
+	"github.com/stretchr/testify/assert"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+func TestHandleAccountServiceErrors(t *testing.T) {
+	tests := map[string]struct {
+		fetcher *mockAccountsFetcher
+		cfg     *config.Configuration
+		want    struct {
+			code     int
+			response string
+		}
+	}{
+		"badRequest": {
+			fetcher: &mockAccountsFetcher{
+				Fail:  true,
+				Error: errors.New("some error"),
+			},
+			cfg: &config.Configuration{
+				AccountDefaults: config.Account{Disabled: true},
+				AccountRequired: true,
+				MaxRequestSize:  maxSize,
+				VTrack: config.VTrack{
+					TimeoutMS: int64(2000), AllowUnknownBidder: false,
+				},
+			},
+			want: struct {
+				code     int
+				response string
+			}{
+				code:     400,
+				response: "Invalid request: some error\nInvalid request: Prebid-server could not verify the Account ID. Please reach out to the prebid server host.\n",
+			},
+		},
+		"serviceUnavailable": {
+			fetcher: &mockAccountsFetcher{
+				Fail: false,
+			},
+			cfg: &config.Configuration{
+				BlacklistedAcctMap: map[string]bool{"testacc": true},
+				MaxRequestSize:     maxSize,
+				VTrack: config.VTrack{
+					TimeoutMS: int64(2000), AllowUnknownBidder: false,
+				},
+			},
+			want: struct {
+				code     int
+				response string
+			}{
+				code:     503,
+				response: "Invalid request: Prebid-server has disabled Account ID: testacc, please reach out to the prebid server host.\n",
+			},
+		},
+		"timeout": {
+			fetcher: &mockAccountsFetcher{
+				Fail:       false,
+				DurationMS: 50,
+			},
+			cfg: &config.Configuration{
+				AccountDefaults: config.Account{Disabled: true},
+				AccountRequired: true,
+				Event: config.Event{
+					TimeoutMS: 1,
+				},
+				MaxRequestSize: maxSize,
+				VTrack: config.VTrack{
+					TimeoutMS:          int64(1),
+					AllowUnknownBidder: false,
+				},
+			},
+			want: struct {
+				code     int
+				response string
+			}{
+				code:     504,
+				response: "Invalid request: context deadline exceeded\nInvalid request: Prebid-server could not verify the Account ID. Please reach out to the prebid server host.\n",
+			},
+		},
+	}
+
+	for name, test := range tests {
+
+		handlers := []struct {
+			name string
+			h    httprouter.Handle
+			r    *http.Request
+		}{
+			vast(t, test.cfg, test.fetcher),
+			event(test.cfg, test.fetcher),
+		}
+
+		for _, handler := range handlers {
+			t.Run(handler.name+"-"+name, func(t *testing.T) {
+				test.cfg.MarshalAccountDefaults()
+
+				recorder := httptest.NewRecorder()
+
+				// execute
+				handler.h(recorder, handler.r, nil)
+				d, err := ioutil.ReadAll(recorder.Result().Body)
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				// validate
+				assert.Equal(t, test.want.code, recorder.Result().StatusCode, fmt.Sprintf("Expected %d", test.want.code))
+				assert.Equal(t, test.want.response, string(d))
+			})
+		}
+	}
+}
+
+func event(cfg *config.Configuration, fetcher stored_requests.AccountFetcher) struct {
+	name string
+	h    httprouter.Handle
+	r    *http.Request
+} {
+	return struct {
+		name string
+		h    httprouter.Handle
+		r    *http.Request
+	}{
+		name: "event",
+		h:    NewEventEndpoint(cfg, fetcher, nil),
+		r:    httptest.NewRequest("GET", "/event?t=win&b=test&ts=1234&f=b&x=1&a=testacc", strings.NewReader("")),
+	}
+}
+
+func vast(t *testing.T, cfg *config.Configuration, fetcher stored_requests.AccountFetcher) struct {
+	name string
+	h    httprouter.Handle
+	r    *http.Request
+} {
+	vtrackBody, err := getValidVTrackRequestBody(true, true)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	return struct {
+		name string
+		h    httprouter.Handle
+		r    *http.Request
+	}{
+		name: "vast",
+		h:    NewVTrackEndpoint(cfg, fetcher, &vtrackMockCacheClient{}, adapters.BidderInfos{}),
+		r:    httptest.NewRequest("POST", "/vtrack?a=testacc", strings.NewReader(vtrackBody)),
+	}
+}
diff --git a/endpoints/events/event_test.go b/endpoints/events/event_test.go
new file mode 100644
index 00000000..ba807184
--- /dev/null
+++ b/endpoints/events/event_test.go
@@ -0,0 +1,664 @@
+package events
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"github.com/prebid/prebid-server/analytics"
+	"github.com/prebid/prebid-server/config"
+	"github.com/prebid/prebid-server/stored_requests"
+	"github.com/stretchr/testify/assert"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+)
+
+// Mock Analytics Module
+type eventsMockAnalyticsModule struct {
+	Fail    bool
+	Error   error
+	Invoked bool
+}
+
+func (e *eventsMockAnalyticsModule) LogAuctionObject(ao *analytics.AuctionObject) {
+	if e.Fail {
+		panic(e.Error)
+	}
+	return
+}
+
+func (e *eventsMockAnalyticsModule) LogVideoObject(vo *analytics.VideoObject) {
+	if e.Fail {
+		panic(e.Error)
+	}
+	return
+}
+
+func (e *eventsMockAnalyticsModule) LogCookieSyncObject(cso *analytics.CookieSyncObject) {
+	if e.Fail {
+		panic(e.Error)
+	}
+	return
+}
+
+func (e *eventsMockAnalyticsModule) LogSetUIDObject(so *analytics.SetUIDObject) {
+	if e.Fail {
+		panic(e.Error)
+	}
+	return
+}
+
+func (e *eventsMockAnalyticsModule) LogAmpObject(ao *analytics.AmpObject) {
+	if e.Fail {
+		panic(e.Error)
+	}
+	return
+}
+
+func (e *eventsMockAnalyticsModule) LogNotificationEventObject(ne *analytics.NotificationEvent) {
+	if e.Fail {
+		panic(e.Error)
+	}
+	e.Invoked = true
+
+	return
+}
+
+// Mock Account fetcher
+var mockAccountData = map[string]json.RawMessage{
+	"events_enabled":  json.RawMessage(`{"events_enabled":true}`),
+	"events_disabled": json.RawMessage(`{"events_enabled":false}`),
+}
+
+type mockAccountsFetcher struct {
+	Fail       bool
+	Error      error
+	DurationMS int
+}
+
+func (maf mockAccountsFetcher) FetchAccount(ctx context.Context, accountID string) (json.RawMessage, []error) {
+	if maf.DurationMS > 0 {
+		select {
+		case <-time.After(time.Duration(maf.DurationMS) * time.Millisecond):
+			break
+		case <-ctx.Done():
+			return nil, []error{ctx.Err()}
+		}
+	}
+
+	if account, ok := mockAccountData[accountID]; ok {
+		return account, nil
+	}
+
+	if maf.Fail {
+		return nil, []error{maf.Error}
+	}
+
+	return nil, []error{stored_requests.NotFoundError{accountID, "Account"}}
+}
+
+// Tests
+
+func TestShouldReturnBadRequestWhenTypeIsMissing(t *testing.T) {
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{}
+
+	// mock PBS Analytics Module
+	mockAnalyticsModule := &eventsMockAnalyticsModule{
+		Fail: false,
+	}
+
+	// mock config
+	cfg := &config.Configuration{
+		AccountDefaults: config.Account{},
+	}
+
+	// prepare
+	reqData := ""
+
+	req := httptest.NewRequest("GET", "/event?b=test", strings.NewReader(reqData))
+	recorder := httptest.NewRecorder()
+
+	e := NewEventEndpoint(cfg, mockAccountsFetcher, mockAnalyticsModule)
+
+	// execute
+	e(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 400, recorder.Result().StatusCode, "Expected 400 on request with missing type parameter")
+	assert.Equal(t, "invalid request: parameter 't' is required\n", string(d))
+}
+
+func TestShouldReturnBadRequestWhenTypeIsInvalid(t *testing.T) {
+
+	// mock AccountsFetcher
+	mockAccounts := &mockAccountsFetcher{}
+
+	// mock PBS Analytics Module
+	mockAnalyticsModule := &eventsMockAnalyticsModule{
+		Fail: false,
+	}
+
+	// mock config
+	cfg := &config.Configuration{
+		AccountDefaults: config.Account{},
+	}
+
+	// prepare
+	reqData := ""
+
+	req := httptest.NewRequest("GET", "/event?t=test&b=t", strings.NewReader(reqData))
+	recorder := httptest.NewRecorder()
+
+	e := NewEventEndpoint(cfg, mockAccounts, mockAnalyticsModule)
+
+	// execute
+	e(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 400, recorder.Result().StatusCode, "Expected 400 on request with invalid type parameter")
+	assert.Equal(t, "invalid request: unknown type: 'test'\n", string(d))
+}
+
+func TestShouldReturnBadRequestWhenBidIdIsMissing(t *testing.T) {
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{}
+
+	// mock PBS Analytics Module
+	mockAnalyticsModule := &eventsMockAnalyticsModule{
+		Fail: false,
+	}
+
+	// mock config
+	cfg := &config.Configuration{
+		AccountDefaults: config.Account{},
+	}
+
+	// prepare
+	reqData := ""
+
+	req := httptest.NewRequest("GET", "/event?t=win", strings.NewReader(reqData))
+	recorder := httptest.NewRecorder()
+
+	e := NewEventEndpoint(cfg, mockAccountsFetcher, mockAnalyticsModule)
+
+	// execute
+	e(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 400, recorder.Result().StatusCode, "Expected 400 on request with missing bidid parameter")
+	assert.Equal(t, "invalid request: parameter 'b' is required\n", string(d))
+}
+
+func TestShouldReturnBadRequestWhenTimestampIsInvalid(t *testing.T) {
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{}
+
+	// mock PBS Analytics Module
+	mockAnalyticsModule := &eventsMockAnalyticsModule{
+		Fail: false,
+	}
+
+	// mock config
+	cfg := &config.Configuration{
+		AccountDefaults: config.Account{},
+	}
+
+	// prepare
+	reqData := ""
+
+	req := httptest.NewRequest("GET", "/event?t=win&b=test&ts=q", strings.NewReader(reqData))
+	recorder := httptest.NewRecorder()
+
+	e := NewEventEndpoint(cfg, mockAccountsFetcher, mockAnalyticsModule)
+
+	// execute
+	e(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 400, recorder.Result().StatusCode, "Expected 400 on request with invalid timestamp parameter")
+	assert.Equal(t, "invalid request: invalid request: error parsing timestamp 'q'\n", string(d))
+}
+
+func TestShouldReturnUnauthorizedWhenAccountIsMissing(t *testing.T) {
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{}
+
+	// mock PBS Analytics Module
+	mockAnalyticsModule := &eventsMockAnalyticsModule{
+		Fail: false,
+	}
+
+	// mock config
+	cfg := &config.Configuration{
+		AccountDefaults: config.Account{},
+	}
+
+	// prepare
+	reqData := ""
+
+	req := httptest.NewRequest("GET", "/event?t=win&b=test&ts=1234", strings.NewReader(reqData))
+	recorder := httptest.NewRecorder()
+
+	e := NewEventEndpoint(cfg, mockAccountsFetcher, mockAnalyticsModule)
+
+	// execute
+	e(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 401, recorder.Result().StatusCode, "Expected 401 on request with missing account id parameter")
+	assert.Equal(t, "Account 'a' is required query parameter and can't be empty", string(d))
+}
+
+func TestShouldReturnBadRequestWhenFormatValueIsInvalid(t *testing.T) {
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{}
+
+	// mock PBS Analytics Module
+	mockAnalyticsModule := &eventsMockAnalyticsModule{
+		Fail: false,
+	}
+
+	// mock config
+	cfg := &config.Configuration{
+		AccountDefaults: config.Account{},
+	}
+
+	// prepare
+	reqData := ""
+
+	req := httptest.NewRequest("GET", "/event?t=win&b=test&ts=1234&f=q", strings.NewReader(reqData))
+	recorder := httptest.NewRecorder()
+
+	e := NewEventEndpoint(cfg, mockAccountsFetcher, mockAnalyticsModule)
+
+	// execute
+	e(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 400, recorder.Result().StatusCode, "Expected 400 on request with invalid format parameter")
+	assert.Equal(t, "invalid request: unknown format: 'q'\n", string(d))
+}
+
+func TestShouldReturnBadRequestWhenAnalyticsValueIsInvalid(t *testing.T) {
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{}
+
+	// mock PBS Analytics Module
+	mockAnalyticsModule := &eventsMockAnalyticsModule{
+		Fail: false,
+	}
+
+	// mock config
+	cfg := &config.Configuration{
+		AccountDefaults: config.Account{},
+	}
+
+	// prepare
+	reqData := ""
+
+	req := httptest.NewRequest("GET", "/event?t=win&b=test&ts=1234&f=b&x=4", strings.NewReader(reqData))
+	recorder := httptest.NewRecorder()
+
+	e := NewEventEndpoint(cfg, mockAccountsFetcher, mockAnalyticsModule)
+
+	// execute
+	e(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 400, recorder.Result().StatusCode, "Expected 400 on request with invalid analytics parameter")
+	assert.Equal(t, "invalid request: unknown analytics: '4'\n", string(d))
+}
+
+func TestShouldNotPassEventToAnalyticsReporterWhenAccountNotFound(t *testing.T) {
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{
+		Fail:  true,
+		Error: stored_requests.NotFoundError{ID: "testacc"},
+	}
+
+	// mock PBS Analytics Module
+	mockAnalyticsModule := &eventsMockAnalyticsModule{
+		Fail: false,
+	}
+
+	// mock config
+	cfg := &config.Configuration{
+		AccountDefaults: config.Account{},
+	}
+
+	// prepare
+	reqData := ""
+
+	req := httptest.NewRequest("GET", "/event?t=win&b=test&ts=1234&f=b&x=1&a=testacc", strings.NewReader(reqData))
+	recorder := httptest.NewRecorder()
+
+	e := NewEventEndpoint(cfg, mockAccountsFetcher, mockAnalyticsModule)
+
+	// execute
+	e(recorder, req, nil)
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 401, recorder.Result().StatusCode, "Expected 401 on account not found")
+	assert.Equal(t, "Account 'testacc' doesn't support events", string(d))
+}
+
+func TestShouldNotPassEventToAnalyticsReporterWhenAccountEventNotEnabled(t *testing.T) {
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{
+		Fail: false,
+	}
+
+	// mock PBS Analytics Module
+	mockAnalyticsModule := &eventsMockAnalyticsModule{
+		Fail: false,
+	}
+
+	// mock config
+	cfg := &config.Configuration{
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// prepare
+	reqData := ""
+
+	req := httptest.NewRequest("GET", "/event?t=win&b=test&ts=1234&f=b&x=1&a=events_disabled", strings.NewReader(reqData))
+	recorder := httptest.NewRecorder()
+
+	e := NewEventEndpoint(cfg, mockAccountsFetcher, mockAnalyticsModule)
+
+	// execute
+	e(recorder, req, nil)
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 401, recorder.Result().StatusCode, "Expected 401 on account with events disabled")
+	assert.Equal(t, "Account 'events_disabled' doesn't support events", string(d))
+}
+
+func TestShouldPassEventToAnalyticsReporterWhenAccountEventEnabled(t *testing.T) {
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{
+		Fail: false,
+	}
+
+	// mock PBS Analytics Module
+	mockAnalyticsModule := &eventsMockAnalyticsModule{
+		Fail: false,
+	}
+
+	// mock config
+	cfg := &config.Configuration{
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// prepare
+	reqData := ""
+
+	req := httptest.NewRequest("GET", "/event?t=win&b=test&ts=1234&f=b&x=1&a=events_enabled", strings.NewReader(reqData))
+	recorder := httptest.NewRecorder()
+
+	e := NewEventEndpoint(cfg, mockAccountsFetcher, mockAnalyticsModule)
+
+	// execute
+	e(recorder, req, nil)
+
+	// validate
+	assert.Equal(t, 204, recorder.Result().StatusCode, "Expected 204 when account has events enabled")
+	assert.Equal(t, true, mockAnalyticsModule.Invoked)
+}
+
+func TestShouldNotPassEventToAnalyticsReporterWhenAnalyticsValueIsZero(t *testing.T) {
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{
+		Fail: false,
+	}
+
+	// mock PBS Analytics Module
+	mockAnalyticsModule := &eventsMockAnalyticsModule{
+		Fail: false,
+	}
+
+	// mock config
+	cfg := &config.Configuration{
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// prepare
+	reqData := ""
+
+	req := httptest.NewRequest("GET", "/event?t=win&b=test&ts=1234&f=b&x=0&a=events_enabled", strings.NewReader(reqData))
+	recorder := httptest.NewRecorder()
+
+	e := NewEventEndpoint(cfg, mockAccountsFetcher, mockAnalyticsModule)
+
+	// execute
+	e(recorder, req, nil)
+
+	// validate
+	assert.Equal(t, 204, recorder.Result().StatusCode)
+	assert.Equal(t, true, mockAnalyticsModule.Invoked != true)
+}
+
+func TestShouldRespondWithPixelAndContentTypeWhenRequestFormatIsImage(t *testing.T) {
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{
+		Fail: false,
+	}
+
+	// mock PBS Analytics Module
+	mockAnalyticsModule := &eventsMockAnalyticsModule{
+		Fail: false,
+	}
+
+	// mock config
+	cfg := &config.Configuration{
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// prepare
+	reqData := ""
+
+	req := httptest.NewRequest("GET", "/event?t=win&b=test&ts=1234&f=i&x=1&a=events_enabled", strings.NewReader(reqData))
+	recorder := httptest.NewRecorder()
+
+	e := NewEventEndpoint(cfg, mockAccountsFetcher, mockAnalyticsModule)
+
+	// execute
+	e(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 200, recorder.Result().StatusCode, "Expected 200 with tracking pixel when format is imp")
+	assert.Equal(t, true, mockAnalyticsModule.Invoked)
+	assert.Equal(t, "image/png", recorder.Header().Get("Content-Type"))
+	assert.Equal(t, "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAABHNCSVQICAgIfAhkiAAAAA1JREFUCJljYGBgYAAAAAUAAYehTtQAAAAASUVORK5CYII=", base64.URLEncoding.EncodeToString(d))
+}
+
+func TestShouldRespondWithNoContentWhenRequestFormatIsNotDefined(t *testing.T) {
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{
+		Fail: false,
+	}
+
+	// mock PBS Analytics Module
+	mockAnalyticsModule := &eventsMockAnalyticsModule{
+		Fail: false,
+	}
+
+	// mock config
+	cfg := &config.Configuration{
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// prepare
+	reqData := ""
+
+	req := httptest.NewRequest("GET", "/event?t=imp&b=test&ts=1234&x=1&a=events_enabled", strings.NewReader(reqData))
+	recorder := httptest.NewRecorder()
+
+	e := NewEventEndpoint(cfg, mockAccountsFetcher, mockAnalyticsModule)
+
+	// execute
+	e(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 204, recorder.Result().StatusCode, "Expected 200 with empty response")
+	assert.Equal(t, true, mockAnalyticsModule.Invoked)
+	assert.Equal(t, "", recorder.Header().Get("Content-Type"))
+	assert.Equal(t, 0, len(d))
+}
+
+func TestShouldParseEventCorrectly(t *testing.T) {
+
+	tests := map[string]struct {
+		req      *http.Request
+		expected *analytics.EventRequest
+	}{
+		"one": {
+			req: httptest.NewRequest("GET", "/event?t=win&b=bidId&f=b&ts=1000&x=1&a=accountId&bidder=bidder", strings.NewReader("")),
+			expected: &analytics.EventRequest{
+				Type:      analytics.Win,
+				BidID:     "bidId",
+				Timestamp: 1000,
+				Bidder:    "bidder",
+				AccountID: "",
+				Format:    analytics.Blank,
+				Analytics: analytics.Enabled,
+			},
+		},
+		"two": {
+			req: httptest.NewRequest("GET", "/event?t=win&b=bidId&ts=0&a=accountId", strings.NewReader("")),
+			expected: &analytics.EventRequest{
+				Type:      analytics.Win,
+				BidID:     "bidId",
+				Timestamp: 0,
+				Analytics: analytics.Enabled,
+			},
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+
+			// execute
+			er, errs := ParseEventRequest(test.req)
+
+			// validate
+			assert.Equal(t, 0, len(errs))
+			assert.EqualValues(t, test.expected, er)
+		})
+	}
+}
+
+func TestEventRequestToUrl(t *testing.T) {
+	externalUrl := "http://localhost:8000"
+	tests := map[string]struct {
+		er   *analytics.EventRequest
+		want string
+	}{
+		"one": {
+			er: &analytics.EventRequest{
+				Type:      analytics.Imp,
+				BidID:     "bidid",
+				AccountID: "accountId",
+				Bidder:    "bidder",
+				Timestamp: 1234567,
+				Format:    analytics.Blank,
+				Analytics: analytics.Enabled,
+			},
+			want: "http://localhost:8000/event?t=imp&b=bidid&a=accountId&bidder=bidder&f=b&ts=1234567&x=1",
+		},
+		"two": {
+			er: &analytics.EventRequest{
+				Type:      analytics.Win,
+				BidID:     "bidid",
+				AccountID: "accountId",
+				Bidder:    "bidder",
+				Timestamp: 1234567,
+				Format:    analytics.Image,
+				Analytics: analytics.Disabled,
+			},
+			want: "http://localhost:8000/event?t=win&b=bidid&a=accountId&bidder=bidder&f=i&ts=1234567&x=0",
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			expected := EventRequestToUrl(externalUrl, test.er)
+			// validate
+			assert.Equal(t, test.want, expected)
+		})
+	}
+}
diff --git a/endpoints/events/vtrack_test.go b/endpoints/events/vtrack_test.go
new file mode 100644
index 00000000..0ec04663
--- /dev/null
+++ b/endpoints/events/vtrack_test.go
@@ -0,0 +1,692 @@
+package events
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"github.com/prebid/prebid-server/adapters"
+	"github.com/prebid/prebid-server/config"
+	"github.com/prebid/prebid-server/prebid_cache_client"
+	"github.com/prebid/prebid-server/stored_requests"
+	"github.com/stretchr/testify/assert"
+	"io/ioutil"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+const (
+	maxSize = 1024 * 256
+
+	vastXmlWithImpressionWithContent    = "<VAST version=\"3.0\"><Ad><Wrapper><AdSystem>prebid.org wrapper</AdSystem><VASTAdTagURI><![CDATA[adm2]]></VASTAdTagURI><Impression>content</Impression><Creatives></Creatives></Wrapper></Ad></VAST>"
+	vastXmlWithImpressionWithoutContent = "<VAST version=\"3.0\"><Ad><Wrapper><AdSystem>prebid.org wrapper</AdSystem><VASTAdTagURI><![CDATA[adm2]]></VASTAdTagURI><Impression></Impression><Creatives></Creatives></Wrapper></Ad></VAST>"
+	vastXmlWithoutImpression            = "<VAST version=\"3.0\"><Ad><Wrapper><AdSystem>prebid.org wrapper</AdSystem><VASTAdTagURI><![CDATA[adm2]]></VASTAdTagURI><Creatives></Creatives></Wrapper></Ad></VAST>"
+)
+
+// Mock pbs cache client
+type vtrackMockCacheClient struct {
+	Fail  bool
+	Error error
+	Uuids []string
+}
+
+func (m *vtrackMockCacheClient) PutJson(ctx context.Context, values []prebid_cache_client.Cacheable) ([]string, []error) {
+	if m.Fail {
+		return []string{}, []error{m.Error}
+	}
+	return m.Uuids, []error{}
+}
+func (m *vtrackMockCacheClient) GetExtCacheData() (scheme string, host string, path string) {
+	return
+}
+
+// Test
+func TestShouldRespondWithBadRequestWhenAccountParameterIsMissing(t *testing.T) {
+	// mock pbs cache client
+	mockCacheClient := &vtrackMockCacheClient{}
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{}
+
+	// mock config
+	cfg := &config.Configuration{
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// prepare
+	reqData := ""
+
+	req := httptest.NewRequest("POST", "/vtrack", strings.NewReader(reqData))
+	recorder := httptest.NewRecorder()
+
+	e := vtrackEndpoint{
+		Cfg:         cfg,
+		BidderInfos: nil,
+		Cache:       mockCacheClient,
+		Accounts:    mockAccountsFetcher,
+	}
+
+	// execute
+	e.Handle(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 400, recorder.Result().StatusCode, "Expected 400 on request with missing account parameter")
+	assert.Equal(t, "Account 'a' is required query parameter and can't be empty", string(d))
+}
+
+func TestShouldRespondWithBadRequestWhenRequestBodyIsEmpty(t *testing.T) {
+	// mock pbs cache client
+	mockCacheClient := &vtrackMockCacheClient{}
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{}
+
+	// config
+	cfg := &config.Configuration{
+		MaxRequestSize:  maxSize,
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// prepare
+	reqData := ""
+
+	req := httptest.NewRequest("POST", "/vtrack?a=events_enabled", strings.NewReader(reqData))
+
+	recorder := httptest.NewRecorder()
+
+	e := vtrackEndpoint{
+		Cfg:         cfg,
+		BidderInfos: nil,
+		Cache:       mockCacheClient,
+		Accounts:    mockAccountsFetcher,
+	}
+
+	// execute
+	e.Handle(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 400, recorder.Result().StatusCode, "Expected 400 on request with empty body")
+	assert.Equal(t, "Invalid request: request body is empty\n", string(d))
+}
+
+func TestShouldRespondWithBadRequestWhenRequestBodyIsInvalid(t *testing.T) {
+	// mock pbs cache client
+	mockCacheClient := &vtrackMockCacheClient{}
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{}
+
+	// config
+	cfg := &config.Configuration{
+		MaxRequestSize:  maxSize,
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// prepare
+	reqData := "invalid"
+
+	req := httptest.NewRequest("POST", "/vtrack?a=events_enabled", strings.NewReader(reqData))
+
+	recorder := httptest.NewRecorder()
+
+	e := vtrackEndpoint{
+		Cfg:         cfg,
+		BidderInfos: nil,
+		Cache:       mockCacheClient,
+		Accounts:    mockAccountsFetcher,
+	}
+
+	// execute
+	e.Handle(recorder, req, nil)
+
+	// validate
+	assert.Equal(t, 400, recorder.Result().StatusCode, "Expected 400 on request with invalid body")
+}
+
+func TestShouldRespondWithBadRequestWhenBidIdIsMissing(t *testing.T) {
+	// mock pbs cache client
+	mockCacheClient := &vtrackMockCacheClient{}
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{}
+
+	// config
+	cfg := &config.Configuration{
+		MaxRequestSize:  maxSize,
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// prepare
+	data := &BidCacheRequest{
+		Puts: []prebid_cache_client.Cacheable{
+			{},
+		},
+	}
+
+	reqData, err := json.Marshal(data)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req := httptest.NewRequest("POST", "/vtrack?a=events_enabled", strings.NewReader(string(reqData)))
+
+	recorder := httptest.NewRecorder()
+
+	e := vtrackEndpoint{
+		Cfg:         cfg,
+		BidderInfos: nil,
+		Cache:       mockCacheClient,
+		Accounts:    mockAccountsFetcher,
+	}
+
+	// execute
+	e.Handle(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 400, recorder.Result().StatusCode, "Expected 400 on request with elements missing bidid")
+	assert.Equal(t, "Invalid request: 'bidid' is required field and can't be empty\n", string(d))
+}
+
+func TestShouldRespondWithBadRequestWhenBidderIsMissing(t *testing.T) {
+	// mock pbs cache client
+	mockCacheClient := &vtrackMockCacheClient{}
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{}
+
+	// config
+	cfg := &config.Configuration{
+		MaxRequestSize:  maxSize,
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// prepare
+	data := &BidCacheRequest{
+		Puts: []prebid_cache_client.Cacheable{
+			{
+				BidID: "test",
+			},
+		},
+	}
+
+	reqData, err := json.Marshal(data)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req := httptest.NewRequest("POST", "/vtrack?a=events_enabled", strings.NewReader(string(reqData)))
+
+	recorder := httptest.NewRecorder()
+
+	e := vtrackEndpoint{
+		Cfg:         cfg,
+		BidderInfos: nil,
+		Cache:       mockCacheClient,
+		Accounts:    mockAccountsFetcher,
+	}
+
+	// execute
+	e.Handle(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 400, recorder.Result().StatusCode, "Expected 400 on request with elements missing bidder")
+	assert.Equal(t, "Invalid request: 'bidder' is required field and can't be empty\n", string(d))
+}
+
+func TestShouldRespondWithInternalServerErrorWhenPbsCacheClientFails(t *testing.T) {
+	// mock pbs cache client
+	mockCacheClient := &vtrackMockCacheClient{
+		Fail:  true,
+		Error: fmt.Errorf("pbs cache client failed"),
+	}
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{}
+
+	// config
+	cfg := &config.Configuration{
+		MaxRequestSize: maxSize, VTrack: config.VTrack{
+			TimeoutMS: int64(2000), AllowUnknownBidder: true,
+		},
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// prepare
+	data, err := getValidVTrackRequestBody(false, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req := httptest.NewRequest("POST", "/vtrack?a=events_enabled", strings.NewReader(data))
+
+	recorder := httptest.NewRecorder()
+
+	e := vtrackEndpoint{
+		Cfg:         cfg,
+		BidderInfos: nil,
+		Cache:       mockCacheClient,
+		Accounts:    mockAccountsFetcher,
+	}
+
+	// execute
+	e.Handle(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 500, recorder.Result().StatusCode, "Expected 500 when pbs cache client fails")
+	assert.Equal(t, "Error(s) updating vast: pbs cache client failed\n", string(d))
+}
+
+func TestShouldTolerateAccountNotFound(t *testing.T) {
+	// mock pbs cache client
+	mockCacheClient := &vtrackMockCacheClient{}
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{
+		Fail:  true,
+		Error: stored_requests.NotFoundError{},
+	}
+
+	// config
+	cfg := &config.Configuration{
+		MaxRequestSize: maxSize, VTrack: config.VTrack{
+			TimeoutMS: int64(2000), AllowUnknownBidder: false,
+		},
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// prepare
+	data, err := getValidVTrackRequestBody(true, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req := httptest.NewRequest("POST", "/vtrack?a=1235", strings.NewReader(data))
+
+	recorder := httptest.NewRecorder()
+
+	e := vtrackEndpoint{
+		Cfg:         cfg,
+		BidderInfos: nil,
+		Cache:       mockCacheClient,
+		Accounts:    mockAccountsFetcher,
+	}
+
+	// execute
+	e.Handle(recorder, req, nil)
+
+	// validate
+	assert.Equal(t, 200, recorder.Result().StatusCode, "Expected 200 when account is not found and request is valid")
+	assert.Equal(t, "application/json", recorder.Header().Get("Content-Type"))
+}
+
+func TestShouldSendToCacheExpectedPutsAndUpdatableBiddersWhenBidderVastNotAllowed(t *testing.T) {
+	// mock pbs cache client
+	mockCacheClient := &vtrackMockCacheClient{
+		Fail:  false,
+		Uuids: []string{"uuid1"},
+	}
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{
+		Fail: false,
+	}
+
+	// config
+	cfg := &config.Configuration{
+		MaxRequestSize: maxSize, VTrack: config.VTrack{
+			TimeoutMS: int64(2000), AllowUnknownBidder: false,
+		},
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// bidder info
+	bidderInfos := make(adapters.BidderInfos)
+	bidderInfos["bidder"] = adapters.BidderInfo{
+		Status:                  adapters.StatusActive,
+		ModifyingVastXmlAllowed: false,
+	}
+	bidderInfos["updatable_bidder"] = adapters.BidderInfo{
+		Status:                  adapters.StatusActive,
+		ModifyingVastXmlAllowed: true,
+	}
+
+	// prepare
+	data, err := getValidVTrackRequestBody(false, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req := httptest.NewRequest("POST", "/vtrack?a=events_enabled", strings.NewReader(data))
+
+	recorder := httptest.NewRecorder()
+
+	e := vtrackEndpoint{
+		Cfg:         cfg,
+		BidderInfos: bidderInfos,
+		Cache:       mockCacheClient,
+		Accounts:    mockAccountsFetcher,
+	}
+
+	// execute
+	e.Handle(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 200, recorder.Result().StatusCode, "Expected 200 when account is not found and request is valid")
+	assert.Equal(t, "{\"responses\":[{\"uuid\":\"uuid1\"}]}", string(d), "Expected 200 when account is found and request is valid")
+	assert.Equal(t, "application/json", recorder.Header().Get("Content-Type"))
+}
+
+func TestShouldSendToCacheExpectedPutsAndUpdatableBiddersWhenBidderVastAllowed(t *testing.T) {
+	// mock pbs cache client
+	mockCacheClient := &vtrackMockCacheClient{
+		Fail:  false,
+		Uuids: []string{"uuid1", "uuid2"},
+	}
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{
+		Fail: false,
+	}
+
+	// config
+	cfg := &config.Configuration{
+		MaxRequestSize: maxSize, VTrack: config.VTrack{
+			TimeoutMS: int64(2000), AllowUnknownBidder: false,
+		},
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// bidder info
+	bidderInfos := make(adapters.BidderInfos)
+	bidderInfos["bidder"] = adapters.BidderInfo{
+		Status:                  adapters.StatusActive,
+		ModifyingVastXmlAllowed: true,
+	}
+	bidderInfos["updatable_bidder"] = adapters.BidderInfo{
+		Status:                  adapters.StatusActive,
+		ModifyingVastXmlAllowed: true,
+	}
+
+	// prepare
+	data, err := getValidVTrackRequestBody(true, true)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req := httptest.NewRequest("POST", "/vtrack?a=events_enabled", strings.NewReader(data))
+
+	recorder := httptest.NewRecorder()
+
+	e := vtrackEndpoint{
+		Cfg:         cfg,
+		BidderInfos: bidderInfos,
+		Cache:       mockCacheClient,
+		Accounts:    mockAccountsFetcher,
+	}
+
+	// execute
+	e.Handle(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 200, recorder.Result().StatusCode, "Expected 200 when account is not found and request is valid")
+	assert.Equal(t, "{\"responses\":[{\"uuid\":\"uuid1\"},{\"uuid\":\"uuid2\"}]}", string(d), "Expected 200 when account is found and request is valid")
+	assert.Equal(t, "application/json", recorder.Header().Get("Content-Type"))
+}
+
+func TestShouldSendToCacheExpectedPutsAndUpdatableUnknownBiddersWhenUnknownBidderIsAllowed(t *testing.T) {
+	// mock pbs cache client
+	mockCacheClient := &vtrackMockCacheClient{
+		Fail:  false,
+		Uuids: []string{"uuid1", "uuid2"},
+	}
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{
+		Fail: false,
+	}
+
+	// config
+	cfg := &config.Configuration{
+		MaxRequestSize: maxSize, VTrack: config.VTrack{
+			TimeoutMS: int64(2000), AllowUnknownBidder: true,
+		},
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// bidder info
+	bidderInfos := make(adapters.BidderInfos)
+
+	// prepare
+	data, err := getValidVTrackRequestBody(true, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req := httptest.NewRequest("POST", "/vtrack?a=events_enabled", strings.NewReader(data))
+
+	recorder := httptest.NewRecorder()
+
+	e := vtrackEndpoint{
+		Cfg:         cfg,
+		BidderInfos: bidderInfos,
+		Cache:       mockCacheClient,
+		Accounts:    mockAccountsFetcher,
+	}
+
+	// execute
+	e.Handle(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 200, recorder.Result().StatusCode, "Expected 200 when account is not found and request is valid")
+	assert.Equal(t, "{\"responses\":[{\"uuid\":\"uuid1\"},{\"uuid\":\"uuid2\"}]}", string(d), "Expected 200 when account is found, request has unknown bidders but allowUnknownBidders is enabled")
+	assert.Equal(t, "application/json", recorder.Header().Get("Content-Type"))
+}
+
+func TestShouldReturnBadRequestWhenRequestExceedsMaxRequestSize(t *testing.T) {
+	// mock pbs cache client
+	mockCacheClient := &vtrackMockCacheClient{
+		Fail:  false,
+		Uuids: []string{"uuid1", "uuid2"},
+	}
+
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{
+		Fail: false,
+	}
+
+	// config
+	cfg := &config.Configuration{
+		MaxRequestSize: 1,
+		VTrack: config.VTrack{
+			TimeoutMS: int64(2000), AllowUnknownBidder: true,
+		},
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// bidder info
+	bidderInfos := make(adapters.BidderInfos)
+
+	// prepare
+	data, err := getValidVTrackRequestBody(true, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req := httptest.NewRequest("POST", "/vtrack?a=events_enabled", strings.NewReader(data))
+
+	recorder := httptest.NewRecorder()
+
+	e := vtrackEndpoint{
+		Cfg:         cfg,
+		BidderInfos: bidderInfos,
+		Cache:       mockCacheClient,
+		Accounts:    mockAccountsFetcher,
+	}
+
+	// execute
+	e.Handle(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 400, recorder.Result().StatusCode, "Expected 400 when request exceeds max request size")
+	assert.Equal(t, "Invalid request: request size exceeded max size of 1 bytes\n", string(d))
+}
+
+func TestShouldRespondWithInternalErrorPbsCacheIsNotConfigured(t *testing.T) {
+	// mock AccountsFetcher
+	mockAccountsFetcher := &mockAccountsFetcher{
+		Fail: false,
+	}
+
+	// config
+	cfg := &config.Configuration{
+		MaxRequestSize: maxSize, VTrack: config.VTrack{
+			TimeoutMS: int64(2000), AllowUnknownBidder: false,
+		},
+		AccountDefaults: config.Account{},
+	}
+	cfg.MarshalAccountDefaults()
+
+	// prepare
+	data, err := getValidVTrackRequestBody(true, true)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req := httptest.NewRequest("POST", "/vtrack?a=events_enabled", strings.NewReader(data))
+	recorder := httptest.NewRecorder()
+
+	e := vtrackEndpoint{
+		Cfg:         cfg,
+		BidderInfos: nil,
+		Cache:       nil,
+		Accounts:    mockAccountsFetcher,
+	}
+
+	// execute
+	e.Handle(recorder, req, nil)
+
+	d, err := ioutil.ReadAll(recorder.Result().Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// validate
+	assert.Equal(t, 500, recorder.Result().StatusCode, "Expected 500 when pbs cache is not configured")
+	assert.Equal(t, "PBS Cache client is not configured", string(d))
+}
+
+func TestVastUrlShouldReturnExpectedUrl(t *testing.T) {
+	url := GetVastUrlTracking("http://external-url", "bidId", "bidder", "accountId", 1000)
+	assert.Equal(t, "http://external-url/event?t=imp&b=bidId&a=accountId&bidder=bidder&f=b&ts=1000", url, "Invalid vast url")
+}
+
+func getValidVTrackRequestBody(withImpression bool, withContent bool) (string, error) {
+	d, e := getVTrackRequestData(withImpression, withContent)
+
+	if e != nil {
+		return "", e
+	}
+
+	req := &BidCacheRequest{
+		Puts: []prebid_cache_client.Cacheable{
+			{
+				Type:       prebid_cache_client.TypeXML,
+				BidID:      "bidId1",
+				Bidder:     "bidder",
+				Data:       d,
+				TTLSeconds: 3600,
+				Timestamp:  1000,
+			},
+			{
+				Type:       prebid_cache_client.TypeXML,
+				BidID:      "bidId2",
+				Bidder:     "updatable_bidder",
+				Data:       d,
+				TTLSeconds: 3600,
+				Timestamp:  1000,
+			},
+		},
+	}
+
+	buf := &bytes.Buffer{}
+	enc := json.NewEncoder(buf)
+	enc.SetEscapeHTML(false)
+
+	e = enc.Encode(req)
+
+	return buf.String(), e
+}
+
+func getVTrackRequestData(wi bool, wic bool) (db []byte, e error) {
+	data := &bytes.Buffer{}
+	enc := json.NewEncoder(data)
+	enc.SetEscapeHTML(false)
+
+	if wi && wic {
+		e = enc.Encode(vastXmlWithImpressionWithContent)
+		return data.Bytes(), e
+	} else if wi {
+		e = enc.Encode(vastXmlWithImpressionWithoutContent)
+	} else {
+		enc.Encode(vastXmlWithoutImpression)
+	}
+
+	return data.Bytes(), e
+}
diff --git a/endpoints/openrtb2/amp_auction_test.go b/endpoints/openrtb2/amp_auction_test.go
index 57e0a7b4..be57271e 100644
--- a/endpoints/openrtb2/amp_auction_test.go
+++ b/endpoints/openrtb2/amp_auction_test.go
@@ -11,6 +11,7 @@ import (
 	"strconv"
 	"testing"
 
+	"github.com/prebid/prebid-server/analytics"
 	"github.com/prebid/prebid-server/stored_requests"
 	"github.com/prebid/prebid-server/stored_requests/backends/empty_fetcher"
 
@@ -1133,3 +1134,165 @@ func TestSetEffectiveAmpPubID(t *testing.T) {
 		}
 	}
 }
+
+type mockLogger struct {
+	ampObject *analytics.AmpObject
+}
+
+func newMockLogger(ao *analytics.AmpObject) analytics.PBSAnalyticsModule {
+	return &mockLogger{
+		ampObject: ao,
+	}
+}
+
+func (logger mockLogger) LogAuctionObject(ao *analytics.AuctionObject) {
+	return
+}
+func (logger mockLogger) LogVideoObject(vo *analytics.VideoObject) {
+	return
+}
+func (logger mockLogger) LogCookieSyncObject(cookieObject *analytics.CookieSyncObject) {
+	return
+}
+func (logger mockLogger) LogSetUIDObject(uuidObj *analytics.SetUIDObject) {
+	return
+}
+func (logger mockLogger) LogNotificationEventObject(uuidObj *analytics.NotificationEvent) {
+	return
+}
+func (logger mockLogger) LogAmpObject(ao *analytics.AmpObject) {
+	*logger.ampObject = *ao
+}
+
+func TestBuildAmpObject(t *testing.T) {
+	testCases := []struct {
+		description       string
+		inTagId           string
+		inStoredRequest   json.RawMessage
+		expectedAmpObject *analytics.AmpObject
+	}{
+		{
+			description:     "Stored Amp request with nil body. Only the error gets logged",
+			inTagId:         "test",
+			inStoredRequest: nil,
+			expectedAmpObject: &analytics.AmpObject{
+				Status: http.StatusOK,
+				Errors: []error{fmt.Errorf("unexpected end of JSON input")},
+			},
+		},
+		{
+			description:     "Stored Amp request with no imps that should return error. Only the error gets logged",
+			inTagId:         "test",
+			inStoredRequest: json.RawMessage(`{"id":"some-request-id","site":{"page":"prebid.org"},"imp":[],"tmax":500}`),
+			expectedAmpObject: &analytics.AmpObject{
+				Status: http.StatusOK,
+				Errors: []error{fmt.Errorf("data for tag_id='test' does not define the required imp array")},
+			},
+		},
+		{
+			description:     "Wrong tag_id, error gets logged",
+			inTagId:         "unknown",
+			inStoredRequest: json.RawMessage(`{"id":"some-request-id","site":{"page":"prebid.org"},"imp":[{"id":"some-impression-id","banner":{"format":[{"w":300,"h":250}]},"ext":{"appnexus":{"placementId":12883451}}}],"tmax":500}`),
+			expectedAmpObject: &analytics.AmpObject{
+				Status: http.StatusOK,
+				Errors: []error{fmt.Errorf("unexpected end of JSON input")},
+			},
+		},
+		{
+			description:     "Valid stored Amp request, correct tag_id, a valid response should be logged",
+			inTagId:         "test",
+			inStoredRequest: json.RawMessage(`{"id":"some-request-id","site":{"page":"prebid.org"},"imp":[{"id":"some-impression-id","banner":{"format":[{"w":300,"h":250}]},"ext":{"appnexus":{"placementId":12883451}}}],"tmax":500}`),
+			expectedAmpObject: &analytics.AmpObject{
+				Status: http.StatusOK,
+				Errors: nil,
+				Request: &openrtb.BidRequest{
+					ID: "some-request-id",
+					Device: &openrtb.Device{
+						IP: "192.0.2.1",
+					},
+					Site: &openrtb.Site{
+						Page:      "prebid.org",
+						Publisher: &openrtb.Publisher{},
+						Ext:       json.RawMessage(`{"amp":1}`),
+					},
+					Imp: []openrtb.Imp{
+						{
+							ID: "some-impression-id",
+							Banner: &openrtb.Banner{
+								Format: []openrtb.Format{
+									{
+										W: 300,
+										H: 250,
+									},
+								},
+							},
+							Secure: func(val int8) *int8 { return &val }(1), //(*int8)(1),
+							Ext:    json.RawMessage(`{"appnexus":{"placementId":12883451}}`),
+						},
+					},
+					AT:   1,
+					TMax: 500,
+					Ext:  json.RawMessage(`{"prebid":{"cache":{"bids":{"returnCreative":null},"vastxml":null},"targeting":{"pricegranularity":{"precision":2,"ranges":[{"min":0,"max":20,"increment":0.1}]},"includewinners":true,"includebidderkeys":true,"includebrandcategory":null,"includeformat":false,"durationrangesec":null}}}`),
+				},
+				AuctionResponse: &openrtb.BidResponse{
+					SeatBid: []openrtb.SeatBid{{
+						Bid: []openrtb.Bid{{
+							AdM: "<script></script>",
+							Ext: json.RawMessage(`{ "prebid": {"targeting": { "hb_pb": "1.20", "hb_appnexus_pb": "1.20", "hb_cache_id": "some_id"}}}`),
+						}},
+						Seat: "",
+					}},
+					Ext: json.RawMessage(`{ "errors": {"openx":[ { "code": 1, "message": "The request exceeded the timeout allocated" } ] } }`),
+				},
+				AmpTargetingValues: map[string]string{
+					"hb_appnexus_pb": "1.20",
+					"hb_cache_id":    "some_id",
+					"hb_pb":          "1.20",
+				},
+				Origin: "",
+			},
+		},
+	}
+
+	request := httptest.NewRequest("GET", "/openrtb2/auction/amp?tag_id=test", nil)
+	recorder := httptest.NewRecorder()
+
+	for _, test := range testCases {
+
+		// Set up test, declare a new mock logger every time
+		actualAmpObject := new(analytics.AmpObject)
+
+		logger := newMockLogger(actualAmpObject)
+
+		mockAmpFetcher := &mockAmpStoredReqFetcher{
+			data: map[string]json.RawMessage{
+				test.inTagId: json.RawMessage(test.inStoredRequest),
+			},
+		}
+
+		endpoint, _ := NewAmpEndpoint(
+			&mockAmpExchange{},
+			newParamsValidator(t),
+			mockAmpFetcher,
+			empty_fetcher.EmptyFetcher{},
+			empty_fetcher.EmptyFetcher{},
+			&config.Configuration{MaxRequestSize: maxSize},
+			pbsmetrics.NewMetrics(metrics.NewRegistry(), openrtb_ext.BidderList(), config.DisabledMetrics{}),
+			logger,
+			map[string]string{},
+			[]byte{},
+			openrtb_ext.BidderMap,
+		)
+
+		// Run test
+		endpoint(recorder, request, nil)
+
+		// assert AmpObject
+		assert.Equalf(t, test.expectedAmpObject.Status, actualAmpObject.Status, "Amp Object Status field doesn't match expected: %s\n", test.description)
+		assert.Lenf(t, actualAmpObject.Errors, len(test.expectedAmpObject.Errors), "Amp Object Errors array doesn't match expected: %s\n", test.description)
+		assert.Equalf(t, test.expectedAmpObject.Request, actualAmpObject.Request, "Amp Object BidRequest doesn't match expected: %s\n", test.description)
+		assert.Equalf(t, test.expectedAmpObject.AuctionResponse, actualAmpObject.AuctionResponse, "Amp Object BidResponse doesn't match expected: %s\n", test.description)
+		assert.Equalf(t, test.expectedAmpObject.AmpTargetingValues, actualAmpObject.AmpTargetingValues, "Amp Object AmpTargetingValues doesn't match expected: %s\n", test.description)
+		assert.Equalf(t, test.expectedAmpObject.Origin, actualAmpObject.Origin, "Amp Object Origin field doesn't match expected: %s\n", test.description)
+	}
+}
diff --git a/endpoints/openrtb2/video_auction_test.go b/endpoints/openrtb2/video_auction_test.go
index a4e9bfbb..27741b22 100644
--- a/endpoints/openrtb2/video_auction_test.go
+++ b/endpoints/openrtb2/video_auction_test.go
@@ -1203,6 +1203,8 @@ func (m *mockAnalyticsModule) LogSetUIDObject(so *analytics.SetUIDObject) { retu
 
 func (m *mockAnalyticsModule) LogAmpObject(ao *analytics.AmpObject) { return }
 
+func (m *mockAnalyticsModule) LogNotificationEventObject(ne *analytics.NotificationEvent) { return }
+
 func mockDeps(t *testing.T, ex *mockExchangeVideo) *endpointDeps {
 	theMetrics := pbsmetrics.NewMetrics(metrics.NewRegistry(), openrtb_ext.BidderList(), config.DisabledMetrics{})
 	deps := &endpointDeps{
diff --git a/exchange/cachetest/customcachekey.json b/exchange/cachetest/customcachekey.json
index bb2a37ca..9a9008fe 100644
--- a/exchange/cachetest/customcachekey.json
+++ b/exchange/cachetest/customcachekey.json
@@ -26,14 +26,14 @@
     }],
     "expectedCacheables": [
         {
-            "Type": "json",
-            "TTLSeconds": 660,
-            "Data": "{\"id\": \"appbid001\",\"impid\": \"oneImp\",\"price\": 7.64,\"cat\": [\"11_sports_22\"]}"
+            "type": "json",
+            "ttlseconds": 660,
+            "value": "{\"id\": \"appbid001\",\"impid\": \"oneImp\",\"price\": 7.64,\"cat\": [\"11_sports_22\"]}"
         },
         {
-            "Type": "json",
-            "TTLSeconds": 660,
-            "Data": "{\"id\": \"pubbid001\", \"impid\": \"oneImp\", \"price\": 5.64, \"cat\": [\"33_news_44\"]}"
+            "type": "json",
+            "ttlseconds": 660,
+            "value": "{\"id\": \"pubbid001\", \"impid\": \"oneImp\", \"price\": 5.64, \"cat\": [\"33_news_44\"]}"
         }
     ],
     "defaultTTLs": {
diff --git a/exchange/cachetest/customcachekey_no_bidders.json b/exchange/cachetest/customcachekey_no_bidders.json
index b8521582..9824ed55 100644
--- a/exchange/cachetest/customcachekey_no_bidders.json
+++ b/exchange/cachetest/customcachekey_no_bidders.json
@@ -26,9 +26,9 @@
     }],
     "expectedCacheables": [
         {
-            "Type": "json",
-            "TTLSeconds": 660,
-            "Data": "{\"id\": \"appbid001\", \"impid\": \"oneImp\", \"price\": 7.64, \"cat\": [\"11_sports_22\"]}"
+            "type": "json",
+            "ttlseconds": 660,
+            "value": "{\"id\": \"appbid001\", \"impid\": \"oneImp\", \"price\": 7.64, \"cat\": [\"11_sports_22\"]}"
         }
     ],
     "defaultTTLs": {
diff --git a/exchange/cachetest/customcachekey_no_winners.json b/exchange/cachetest/customcachekey_no_winners.json
index f6204db3..75ef628e 100644
--- a/exchange/cachetest/customcachekey_no_winners.json
+++ b/exchange/cachetest/customcachekey_no_winners.json
@@ -26,14 +26,14 @@
     }],
     "expectedCacheables": [
         {
-            "Type": "json",
-            "TTLSeconds": 660,
-            "Data": "{\"id\": \"appbid001\", \"impid\": \"oneImp\", \"price\": 7.64, \"cat\": [\"11_sports_22\"]}"
+            "type": "json",
+            "ttlseconds": 660,
+            "value": "{\"id\": \"appbid001\", \"impid\": \"oneImp\", \"price\": 7.64, \"cat\": [\"11_sports_22\"]}"
         },
         {
-            "Type": "json",
-            "TTLSeconds": 660,
-            "Data": "{\"id\":\"pubbid001\",\"impid\":\"oneImp\",\"price\":5.64,\"cat\":[\"33_news_44\"]}"
+            "type": "json",
+            "ttlseconds": 660,
+            "value": "{\"id\":\"pubbid001\",\"impid\":\"oneImp\",\"price\":5.64,\"cat\":[\"33_news_44\"]}"
         }
     ],
     "defaultTTLs": {
diff --git a/exchange/cachetest/debuglog_disabled.json b/exchange/cachetest/debuglog_disabled.json
index 88d6332c..a7efeff0 100644
--- a/exchange/cachetest/debuglog_disabled.json
+++ b/exchange/cachetest/debuglog_disabled.json
@@ -36,13 +36,13 @@
     }],
     "expectedCacheables": [
         {
-            "Type": "json",
-            "TTLSeconds": 660,
-            "Data": "{\"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64}"
+            "type": "json",
+            "ttlseconds": 660,
+            "value": "{\"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64}"
         }, {
-            "Type": "json",
-            "TTLSeconds": 3660,
-            "Data": "{\"id\": \"bidTwo\", \"impid\": \"twoImp\", \"price\": 5.64}"
+            "type": "json",
+            "ttlseconds": 3660,
+            "value": "{\"id\": \"bidTwo\", \"impid\": \"twoImp\", \"price\": 5.64}"
         }
     ],
     "defaultTTLs": {
diff --git a/exchange/cachetest/debuglog_enabled.json b/exchange/cachetest/debuglog_enabled.json
index 670b694f..e6c85c57 100644
--- a/exchange/cachetest/debuglog_enabled.json
+++ b/exchange/cachetest/debuglog_enabled.json
@@ -36,17 +36,17 @@
     }],
     "expectedCacheables": [
         {
-            "Type": "json",
-            "TTLSeconds": 660,
-            "Data": "{\"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64}"
+            "type": "json",
+            "ttlseconds": 660,
+            "value": "{\"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64}"
         }, {
-            "Type": "json",
-            "TTLSeconds": 3660,
-            "Data": "{\"id\": \"bidTwo\", \"impid\": \"twoImp\", \"price\": 5.64}"
+            "type": "json",
+            "ttlseconds": 3660,
+            "value": "{\"id\": \"bidTwo\", \"impid\": \"twoImp\", \"price\": 5.64}"
         }, {
-            "Type": "xml",
-            "TTLSeconds": 3600,
-            "Data": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cLog\u003e\u003cRequest\u003etest request string\u003c/Request\u003e\u003cHeaders\u003etest headers string\u003c/Headers\u003e\u003cResponse\u003etest response string\u003c/Response\u003e\u003c/Log\u003e"
+            "type": "xml",
+            "ttlseconds": 3600,
+            "value": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cLog\u003e\u003cRequest\u003etest request string\u003c/Request\u003e\u003cHeaders\u003etest headers string\u003c/Headers\u003e\u003cResponse\u003etest response string\u003c/Response\u003e\u003c/Log\u003e"
         }
     ],
     "defaultTTLs": {
diff --git a/exchange/cachetest/defaultbanner.json b/exchange/cachetest/defaultbanner.json
index ca44589c..8bc59f63 100644
--- a/exchange/cachetest/defaultbanner.json
+++ b/exchange/cachetest/defaultbanner.json
@@ -24,13 +24,13 @@
     }],
     "expectedCacheables": [
         {
-            "Type": "json",
-            "TTLSeconds": 660,
-            "Data":"{ \"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64, \"exp\": 600}"
+            "type": "json",
+            "ttlseconds": 660,
+            "value":"{ \"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64, \"exp\": 600}"
         }, {
-            "Type": "json",
-            "TTLSeconds": 360,
-            "Data": "{ \"id\": \"bidTwo\", \"impid\": \"oneImp\", \"price\": 5.64 }"
+            "type": "json",
+            "ttlseconds": 360,
+            "value": "{ \"id\": \"bidTwo\", \"impid\": \"oneImp\", \"price\": 5.64 }"
         }
     ],
     "defaultTTLs": {
diff --git a/exchange/cachetest/defaultbanner_no_bidders.json b/exchange/cachetest/defaultbanner_no_bidders.json
index d5171821..6dc534b5 100644
--- a/exchange/cachetest/defaultbanner_no_bidders.json
+++ b/exchange/cachetest/defaultbanner_no_bidders.json
@@ -24,9 +24,9 @@
     }],
     "expectedCacheables": [
         {
-            "Type": "json",
-            "TTLSeconds": 660,
-            "Data":"{ \"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64, \"exp\": 600 }"
+            "type": "json",
+            "ttlseconds": 660,
+            "value":"{ \"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64, \"exp\": 600 }"
         }
     ],
     "defaultTTLs": {
diff --git a/exchange/cachetest/defaultbanner_no_winners.json b/exchange/cachetest/defaultbanner_no_winners.json
index fe43462b..154e1faa 100644
--- a/exchange/cachetest/defaultbanner_no_winners.json
+++ b/exchange/cachetest/defaultbanner_no_winners.json
@@ -24,13 +24,13 @@
     }],
     "expectedCacheables": [
         {
-            "Type": "json",
-            "TTLSeconds": 660,
-            "Data":"{ \"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64, \"exp\":   600 }"
+            "type": "json",
+            "ttlseconds": 660,
+            "value":"{ \"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64, \"exp\":   600 }"
         }, {
-            "Type": "json",
-            "TTLSeconds": 360,
-            "Data": "{ \"id\": \"bidTwo\", \"impid\": \"oneImp\", \"price\": 5.64 }"
+            "type": "json",
+            "ttlseconds": 360,
+            "value": "{ \"id\": \"bidTwo\", \"impid\": \"oneImp\", \"price\": 5.64 }"
         }
     ],
     "defaultTTLs": {
diff --git a/exchange/cachetest/defaultvideo.json b/exchange/cachetest/defaultvideo.json
index 4d38585d..8d7fcfed 100644
--- a/exchange/cachetest/defaultvideo.json
+++ b/exchange/cachetest/defaultvideo.json
@@ -26,13 +26,13 @@
     }],
     "expectedCacheables": [
         {
-            "Type": "json",
-            "TTLSeconds": 660,
-            "Data": "{\"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64}"
+            "type": "json",
+            "ttlseconds": 660,
+            "value": "{\"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64}"
         }, {
-            "Type": "json",
-            "TTLSeconds": 3660,
-            "Data": "{\"id\": \"bidTwo\", \"impid\": \"twoImp\", \"price\": 5.64}"
+            "type": "json",
+            "ttlseconds": 3660,
+            "value": "{\"id\": \"bidTwo\", \"impid\": \"twoImp\", \"price\": 5.64}"
         }
     ],
     "defaultTTLs": {
diff --git a/exchange/cachetest/defaultvideo_no_bidders.json b/exchange/cachetest/defaultvideo_no_bidders.json
index 0a9e9f1d..ab8f13ff 100644
--- a/exchange/cachetest/defaultvideo_no_bidders.json
+++ b/exchange/cachetest/defaultvideo_no_bidders.json
@@ -26,9 +26,9 @@
     }],
     "expectedCacheables": [
         {
-            "Type": "json",
-            "TTLSeconds": 660,
-            "Data": "{\"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64}"
+            "type": "json",
+            "ttlseconds": 660,
+            "value": "{\"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64}"
         }
     ],
     "defaultTTLs": {
diff --git a/exchange/cachetest/defaultvideo_no_winners.json b/exchange/cachetest/defaultvideo_no_winners.json
index 98a12a5a..21ad558c 100644
--- a/exchange/cachetest/defaultvideo_no_winners.json
+++ b/exchange/cachetest/defaultvideo_no_winners.json
@@ -28,13 +28,13 @@
     }],
     "expectedCacheables": [
         {
-            "Type": "json",
-            "TTLSeconds": 660,
-            "Data": "{\"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64, \"nurl\": \"http://domain.com/win-notify/1\"}"
+            "type": "json",
+            "ttlseconds": 660,
+            "value": "{\"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64, \"nurl\": \"http://domain.com/win-notify/1\"}"
         }, {
-            "Type": "json",
-            "TTLSeconds": 3660,
-            "Data": "{\"id\": \"bidTwo\", \"impid\": \"twoImp\", \"price\": 5.64, \"nurl\": \"http://domain.com/win-notify/1\"}"
+            "type": "json",
+            "ttlseconds": 3660,
+            "value": "{\"id\": \"bidTwo\", \"impid\": \"twoImp\", \"price\": 5.64, \"nurl\": \"http://domain.com/win-notify/1\"}"
         }
     ],
     "defaultTTLs": {
diff --git a/exchange/cachetest/multibid.json b/exchange/cachetest/multibid.json
index f2405466..09095bd5 100644
--- a/exchange/cachetest/multibid.json
+++ b/exchange/cachetest/multibid.json
@@ -54,25 +54,25 @@
     ],
     "expectedCacheables": [
         {
-            "Type": "json",
-            "TTLSeconds": 360,
-            "Data":"{ \"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64, \"exp\":   600 }"
+            "type": "json",
+            "ttlseconds": 360,
+            "value":"{ \"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64, \"exp\":   600 }"
         }, {
-            "Type": "json",
-            "TTLSeconds": 260,
-            "Data": "{ \"id\": \"bidTwo\", \"impid\": \"oneImp\", \"price\": 5.64, \"exp\":   200 }"
+            "type": "json",
+            "ttlseconds": 260,
+            "value": "{ \"id\": \"bidTwo\", \"impid\": \"oneImp\", \"price\": 5.64, \"exp\":   200 }"
         }, {
-            "Type": "json",
-            "TTLSeconds": 360,
-            "Data": "{ \"id\": \"bidThree\", \"impid\": \"oneImp\", \"price\": 2.3 }"
+            "type": "json",
+            "ttlseconds": 360,
+            "value": "{ \"id\": \"bidThree\", \"impid\": \"oneImp\", \"price\": 2.3 }"
         }, {
-            "Type": "json",
-            "TTLSeconds": 0,
-            "Data": "{ \"id\": \"bidFour\", \"impid\": \"twoImp\", \"price\": 1.64 }"
+            "type": "json",
+            "ttlseconds": 0,
+            "value": "{ \"id\": \"bidFour\", \"impid\": \"twoImp\", \"price\": 1.64 }"
         }, {
-            "Type": "json",
-            "TTLSeconds": 960,
-            "Data": "{ \"id\": \"bidFive\", \"impid\": \"twoImp\", \"price\": 7.64, \"exp\":   900 }"
+            "type": "json",
+            "ttlseconds": 960,
+            "value": "{ \"id\": \"bidFive\", \"impid\": \"twoImp\", \"price\": 7.64, \"exp\":   900 }"
         }
     ],
     "targetDataIncludeWinners":true,
diff --git a/exchange/cachetest/multibid_no_bidders.json b/exchange/cachetest/multibid_no_bidders.json
index 1ec47579..446ae9ca 100644
--- a/exchange/cachetest/multibid_no_bidders.json
+++ b/exchange/cachetest/multibid_no_bidders.json
@@ -54,13 +54,13 @@
     ],
     "expectedCacheables": [
         {
-            "Type": "json",
-            "Data": "{\"id\": \"bidOne\",\"impid\": \"oneImp\",\"price\": 7.64,\"exp\": 600}",
-            "TTLSeconds": 360
+            "type": "json",
+            "value": "{\"id\": \"bidOne\",\"impid\": \"oneImp\",\"price\": 7.64,\"exp\": 600}",
+            "ttlseconds": 360
         }, {
-            "Type": "json",
-            "Data": "{\"id\": \"bidFive\", \"impid\": \"twoImp\", \"price\": 7.64, \"exp\":   900}",
-            "TTLSeconds": 960
+            "type": "json",
+            "value": "{\"id\": \"bidFive\", \"impid\": \"twoImp\", \"price\": 7.64, \"exp\":   900}",
+            "ttlseconds": 960
         }
     ],
     "targetDataIncludeWinners":true,
diff --git a/exchange/cachetest/multibid_no_winners.json b/exchange/cachetest/multibid_no_winners.json
index 2221a54c..2f260076 100644
--- a/exchange/cachetest/multibid_no_winners.json
+++ b/exchange/cachetest/multibid_no_winners.json
@@ -54,25 +54,25 @@
     ],
     "expectedCacheables": [
         {
-            "Type": "json",
-            "TTLSeconds": 360,
-            "Data":"{ \"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64, \"exp\":   600 }"
+            "type": "json",
+            "ttlseconds": 360,
+            "value":"{ \"id\": \"bidOne\", \"impid\": \"oneImp\", \"price\": 7.64, \"exp\":   600 }"
         }, {
-            "Type": "json",
-            "TTLSeconds": 260,
-            "Data": "{ \"id\": \"bidTwo\", \"impid\": \"oneImp\", \"price\": 5.64, \"exp\":   200 }"
+            "type": "json",
+            "ttlseconds": 260,
+            "value": "{ \"id\": \"bidTwo\", \"impid\": \"oneImp\", \"price\": 5.64, \"exp\":   200 }"
         }, {
-            "Type": "json",
-            "TTLSeconds": 360,
-            "Data": "{ \"id\": \"bidThree\", \"impid\": \"oneImp\", \"price\": 2.3 }"
+            "type": "json",
+            "ttlseconds": 360,
+            "value": "{ \"id\": \"bidThree\", \"impid\": \"oneImp\", \"price\": 2.3 }"
         }, {
-            "Type": "json",
-            "TTLSeconds": 0,
-            "Data": "{ \"id\": \"bidFour\", \"impid\": \"twoImp\", \"price\": 1.64 }"
+            "type": "json",
+            "ttlseconds": 0,
+            "value": "{ \"id\": \"bidFour\", \"impid\": \"twoImp\", \"price\": 1.64 }"
         }, {
-            "Type": "json",
-            "TTLSeconds": 960,
-            "Data": "{ \"id\": \"bidFive\", \"impid\": \"twoImp\", \"price\": 7.64, \"exp\":   900 }"
+            "type": "json",
+            "ttlseconds": 960,
+            "value": "{ \"id\": \"bidFive\", \"impid\": \"twoImp\", \"price\": 7.64, \"exp\":   900 }"
         }
     ],
     "targetDataIncludeWinners":false,
diff --git a/exchange/customcachekeytest/customcachekey.json b/exchange/customcachekeytest/customcachekey.json
index e578f980..9b903575 100644
--- a/exchange/customcachekeytest/customcachekey.json
+++ b/exchange/customcachekeytest/customcachekey.json
@@ -28,14 +28,14 @@
     }],
     "expectedCacheables": [
         {
-            "Type": "xml",
-            "TTLSeconds": 660,
-            "Key": "11_sports_22_",
-            "Data":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://domain.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e" 
+            "type": "xml",
+            "ttlseconds": 660,
+            "key": "11_sports_22_",
+            "value":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://domain.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e"
         }, {
-            "Type": "xml",
-            "TTLSeconds": 660,
-            "Data":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://anotherdomain.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e" 
+            "type": "xml",
+            "ttlseconds": 660,
+            "value":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://anotherdomain.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e"
         }
     ],
     "defaultTTLs": {
diff --git a/exchange/customcachekeytest/customcachekey_no_bidders.json b/exchange/customcachekeytest/customcachekey_no_bidders.json
index 0f09c8db..589d1e6b 100644
--- a/exchange/customcachekeytest/customcachekey_no_bidders.json
+++ b/exchange/customcachekeytest/customcachekey_no_bidders.json
@@ -27,10 +27,10 @@
     }],
     "expectedCacheables": [
         {
-            "Type": "xml",
-            "TTLSeconds": 660,
-            "Key": "11_sports_22_",
-            "Data":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://domain.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e" 
+            "type": "xml",
+            "ttlseconds": 660,
+            "key": "11_sports_22_",
+            "value":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://domain.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e"
         }
     ],
     "defaultTTLs": {
diff --git a/exchange/customcachekeytest/customcachekey_no_winners.json b/exchange/customcachekeytest/customcachekey_no_winners.json
index f21c8bda..3eaf6cfb 100644
--- a/exchange/customcachekeytest/customcachekey_no_winners.json
+++ b/exchange/customcachekeytest/customcachekey_no_winners.json
@@ -27,14 +27,14 @@
     }],
     "expectedCacheables": [
         {
-            "Type": "xml",
-            "TTLSeconds": 660,
-            "Key": "11_sports_22_",
-            "Data":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e" 
+            "type": "xml",
+            "ttlseconds": 660,
+            "key": "11_sports_22_",
+            "value":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e"
         }, {
-            "Type": "xml",
-            "TTLSeconds": 660,
-            "Data":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://domain.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e" 
+            "type": "xml",
+            "ttlseconds": 660,
+            "value":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://domain.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e"
         }
     ],
     "defaultTTLs": {
diff --git a/exchange/impcustomcachekeytest/multiImpVast.json b/exchange/impcustomcachekeytest/multiImpVast.json
index bf0b310b..db106974 100644
--- a/exchange/impcustomcachekeytest/multiImpVast.json
+++ b/exchange/impcustomcachekeytest/multiImpVast.json
@@ -64,29 +64,29 @@
 		"bidder": "rubicon"
 	}],
 	"expectedCacheables": [{
-			"Type": "xml",
-			"TTLSeconds": 360,
-            "Data":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://bidoneproducts.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e" 
+			"type": "xml",
+			"ttlseconds": 360,
+            "value":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://bidoneproducts.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e"
 		}, {
-			"Type": "xml",
-			"TTLSeconds": 260,
-			"Key": "34_news_44",
-            "Data":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://domain.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e" 
+			"type": "xml",
+			"ttlseconds": 260,
+			"key": "34_news_44",
+            "value":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://domain.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e"
 		}, {
-			"Type": "xml",
-			"TTLSeconds": 360,
-            "Data":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://anotherdomain.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e" 
+			"type": "xml",
+			"ttlseconds": 360,
+            "value":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://anotherdomain.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e"
 		},
 		{
-			"Type": "xml",
-			"TTLSeconds": 3660,
-            "Data":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://bidseveryday.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e" 
+			"type": "xml",
+			"ttlseconds": 3660,
+            "value":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://bidseveryday.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e"
 		},
 		{
-			"Type": "xml",
-			"TTLSeconds": 960,
-			"Key": "13_sports_22",
-            "Data":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://anotherbidder.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e" 
+			"type": "xml",
+			"ttlseconds": 960,
+			"key": "13_sports_22",
+            "value":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://anotherbidder.com/win-notify/1]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e"
 		}
 	],
 	"defaultTTLs": {
diff --git a/exchange/impcustomcachekeytest/multiImpVideo.json b/exchange/impcustomcachekeytest/multiImpVideo.json
index 7c1d7af0..fc343888 100644
--- a/exchange/impcustomcachekeytest/multiImpVideo.json
+++ b/exchange/impcustomcachekeytest/multiImpVideo.json
@@ -60,32 +60,32 @@
 		"bidder": "rubicon"
 	}],
 	"expectedCacheables": [{
-			"Type": "xml",
-			"TTLSeconds": 3660,
-			"Data": "\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://apn_1_0.com]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e",
-			"Key": "12.00_sports_30s"
+			"type": "xml",
+			"ttlseconds": 3660,
+			"value": "\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://apn_1_0.com]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e",
+			"key": "12.00_sports_30s"
 		}, {
-			"Type": "xml",
-			"TTLSeconds": 3660,
-			"Data": "\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://spotx_1_0.com]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e",
-			"Key": "20_news_30s"
+			"type": "xml",
+			"ttlseconds": 3660,
+			"value": "\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://spotx_1_0.com]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e",
+			"key": "20_news_30s"
 		}, {
-			"Type": "xml",
-			"TTLSeconds": 3660,
-			"Data": "\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://apn_1_1.com]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e",
-			"Key": "18_furniture_30s"
+			"type": "xml",
+			"ttlseconds": 3660,
+			"value": "\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://apn_1_1.com]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e",
+			"key": "18_furniture_30s"
 		},
 		{
-			"Type": "xml",
-			"TTLSeconds": 3660,
-			"Data": "\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://spotx_1_1.com]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e",
-			"Key": "17_auto_30s"
+			"type": "xml",
+			"ttlseconds": 3660,
+			"value": "\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://spotx_1_1.com]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e",
+			"key": "17_auto_30s"
 		},
 		{
-			"Type": "xml",
-			"TTLSeconds": 3660,
-			"Data": "\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://rubicon_1_1.com]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e",
-			"Key": "17_music_30s"
+			"type": "xml",
+			"ttlseconds": 3660,
+			"value": "\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://rubicon_1_1.com]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e",
+			"key": "17_music_30s"
 		}
 	],
 	"defaultTTLs": {
diff --git a/exchange/impcustomcachekeytest/multiImpVideoNoIncludeBidderKeys.json b/exchange/impcustomcachekeytest/multiImpVideoNoIncludeBidderKeys.json
index 0b8ee141..4dd15344 100644
--- a/exchange/impcustomcachekeytest/multiImpVideoNoIncludeBidderKeys.json
+++ b/exchange/impcustomcachekeytest/multiImpVideoNoIncludeBidderKeys.json
@@ -61,16 +61,16 @@
 	}],
 	"expectedCacheables": [
 		{
-			"Type": "xml",
-			"TTLSeconds": 3660,
-			"Data": "\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://spotx_1_0.com]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e",
-			"Key": "20_news_30s"
+			"type": "xml",
+			"ttlseconds": 3660,
+			"value": "\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://spotx_1_0.com]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e",
+			"key": "20_news_30s"
 		},
 		{
-			"Type": "xml",
-			"TTLSeconds": 3660,
-			"Data": "\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://apn_1_1.com]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e",
-			"Key": "18_furniture_30s"
+			"type": "xml",
+			"ttlseconds": 3660,
+			"value": "\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cAdSystem\u003eprebid.org wrapper\u003c/AdSystem\u003e\u003cVASTAdTagURI\u003e\u003c![CDATA[http://apn_1_1.com]]\u003e\u003c/VASTAdTagURI\u003e\u003cImpression\u003e\u003c/Impression\u003e\u003cCreatives\u003e\u003c/Creatives\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e",
+			"key": "18_furniture_30s"
 		}
 	],
 	"defaultTTLs": {
diff --git a/prebid_cache_client/client_test.go b/prebid_cache_client/client_test.go
index 72fd2761..0bd29699 100644
--- a/prebid_cache_client/client_test.go
+++ b/prebid_cache_client/client_test.go
@@ -174,8 +174,11 @@ func TestEncodeValueToBuffer(t *testing.T) {
 		Type:       TypeJSON,
 		Data:       json.RawMessage(`{}`),
 		TTLSeconds: 300,
+		BidID:      "bid",
+		Bidder:     "bdr",
+		Timestamp:  123456789,
 	}
-	expected := string(`{"type":"json","ttlseconds":300,"value":{}}`)
+	expected := string(`{"type":"json","ttlseconds":300,"value":{},"bidid":"bid","bidder":"bdr","timestamp":123456789}`)
 	_ = encodeValueToBuffer(testCache, false, buf)
 	actual := buf.String()
 	assertStringEqual(t, expected, actual)