Skip to main content

Implementing Carousels

This section covers the request options and response fields specific to product carousel creatives. For how to build and send a bid request, see Building Bid Requests.

Carousel requests use .andProductCards() instead of .andBanner() or .andVideo(). Pass ads.defaultNative() to build the native request format used for product cards. A request typically covers more than the carousel slot alone — here, a carousel alongside a banner:

/* const ads = await LoadSurfAdsCoreHeadless({ ... }) — see Initialization */

const request = ads.startRequest('your-zone-id')
.withImp('carousel-zone-id')
.andProductCards(ads.defaultNative())
.withImp('banner-zone-id')
.andBanner(4, 1)
.build();

const bidsRes = await ads.requestBids(request);
if (!bidsRes.ok) {
console.error(bidsRes.error);
return;
}

Note: Surfside implements two separate OpenRTB specifications: OpenRTB 2.6 (the main bid request, built with startRequest) and OpenRTB Native 1.2 (the native ad payload, built with defaultNative). Because they are distinct specs, they have distinct builders. andProductCards() accepts a native request built by defaultNative() and embeds it inside the outer OpenRTB 2.6 request.

You can also pass categories and keywords for targeting before calling .withImp():

const request = ads.startRequest('your-zone-id')
.withSurfCategory(['edibles'])
.withKeywords(['organic', 'sale'])
.withImp('carousel-zone-id')
.andProductCards(ads.defaultNative())
.withImp('banner-zone-id')
.andBanner(4, 1)
.build();

buildAllSimpleCarousels takes the full bid response and returns a Result containing an array of carousels, pulling out just the carousel creatives even if the request also asked for other creative types. Each carousel has a products array of resolved product cards, where each item includes the product data, clickthrough URL, and whether it is a sponsored placement.

const carouselsRes = await ads.buildAllSimpleCarousels(bidsRes.value);
if (!carouselsRes.ok) {
console.error(carouselsRes.error);
return;
}

If the request mixed creative types on the same impression, use buildAllResponsesCore instead (see Building Bid Requests) and switch on creative.type === 'simpleCarousel'.

Writing the output​

Each product card has a product object with fields like name, image, and price, a clickthrough URL, and a sponsored flag. How you serialize and deliver this is up to your application — for example, writing it as JSON for a frontend to consume:

for (const carousel of carouselsRes.value) {
for (const item of carousel.products) {
res.write(JSON.stringify({
name: item.product.name,
image: item.product.image,
clickthrough: item.clickthrough,
sponsored: item.sponsored
}));
}
}

Tracking​

Unlike banners and videos, the OpenRTB Native 1.2 spec supports multiple named tracking events per creative.

There are two types of tracker:

  • Pixel (type: 'pixel') — a URL to fire as a 1×1 <img> tag or a fetch call.
  • Script (type: 'script') — a URL to load as a <script src="..."> tag. Script trackers are optional; you are not required to fire them.

Win and impression​

win and impression are tracked per carousel, not per product card — fire them once for the carousel as a whole, no matter how many products it contains. They live on the carousel's own trackers field, each as an array of trackers (a carousel can carry more than one tracker for the same event), and are always pixel trackers.

for (const carousel of carouselsRes.value) {
carousel.trackers
.win
.filter(t => t.type === 'pixel')
.forEach(t => res.write(`<img src="${t.url}" width="1" height="1" style="display:none">`));

carousel.trackers
.impression
.filter(t => t.type === 'pixel')
.forEach(t => res.write(`<img src="${t.url}" width="1" height="1" style="display:none">`));

for (const item of carousel.products) {
// ...render each product card
}
}

Or, from the client side, fire them with fetch immediately before the carousel renders:

carousel.trackers.win.filter(t => t.type === 'pixel').forEach(t => fetch(t.url));
carousel.trackers.impression.filter(t => t.type === 'pixel').forEach(t => fetch(t.url));

Firing win or impression per product card instead of once per carousel will overcount — don't loop these over carousel.products.

Viewability​

Unlike win and impression, viewability is tracked per product card, not per carousel — each card can scroll into view independently, so each needs its own check. Don't confuse this with the carousel-level impression tracker above: viewability pixels live on the card itself, under card.trackers.namedTrackers.viewable, filtered to pixel type:

card.trackers
?.namedTrackers
?.viewable
?.filter(t => t.type === 'pixel')

As with banner viewability, fire these once the card meets your viewability threshold, using an IntersectionObserver per card rather than a static pixel:

// Adapt this for your framework
for (const card of carousel.products) {
const viewabilityTrackers = card.trackers
?.namedTrackers
?.viewable
?.filter(t => t.type === 'pixel') ?? [];

if (!viewabilityTrackers.length) {
continue;
}

let timer;
let hasFired = false;

const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio > 0.5 && !hasFired) {
timer = setTimeout(() => {
viewabilityTrackers.forEach(t => navigator.sendBeacon(t.url));
hasFired = true;
}, 1_000); // dwell time, 1 second
} else {
clearTimeout(timer);
timer = undefined;
}
});
}, { threshold: 0.5 }); // 50% viewable

observer.observe(cardElement);
}