Skip to main content

Initializing Surfside Ads

Before you can request ads, you need to initialize the Surfside SDK for the current request. How you do that depends on which integration method you're using.

Surfside Ads is broken up into 4 kinds of functions:

  • Loading dependencies — Surfside Ads makes a couple network requests in order to load clientside configurations, geo-location information, and other settings. By default, Surfside Ads requests dependencies from the Surfside CDN, but this behavior can be overridden to implement local caching, mock services, or custom configuration workflows.
  • Initializing Services — Surfside Ads manages its own dependency injection, so you must call the Surfside Ads Initialization functions at the correct time. Services are initialized based on the current site's metadata and the current page URL, so it is important that you re-inject dependencies in an optimized way.
  • Sending Bid Requests — Surfside Ads provides utilities for generating OpenRTB 2.6 bid requests, including Surfside's custom extensions for supporting product carousel ads.
  • Parsing Bid Responses — Surfside Ads performs macro expansion on bid responses and parses through the custom Surfside extensions to return data which is more useful than the standard OpenRTB 2.6 Bid Response. For carousel bid responses, Surfside Ads sends an HTTP request to the Surfside CDN in order to load product data. This behavior can be overridden if you want to use your own product database.

Initialization loads your account configuration, resolves targeting context, and wires together the services you'll use; returning a single API object that's ready to go.

Call LoadSurfAdsCoreHeadless once per request to get that object.

Installation​

The headless workflow ships in @surfside/ads-core. It has no browser dependency and runs on Node 18+, Bun, or Deno.

npm install @surfside/ads-core

TypeScript types are bundled — there is no separate @types package. The package publishes both ESM and CommonJS builds, so import and require both work.

Quick start​

import { LoadSurfAdsCoreHeadless } from '@surfside/ads-core';

const adsRes = await LoadSurfAdsCoreHeadless({
accountId: 'your-account-id',
siteId: 'your-site-id',
channelId: 'your-channel-id',
locationId: 'your-location-id',
userId: 'user-123', // optional
ip: req.ip,
userAgent: req.headers['user-agent'] ?? '',
url: 'https://example.com/some-page',
});

if (!adsRes.ok) {
console.error(adsRes.error);
return;
}

const ads = adsRes.value;

Configuration reference​

LoadSurfAdsCoreHeadless takes a single config object. Every field it accepts is listed here.

FieldTypeRequiredDefaultDescription
accountIdstringYes—Your Surfside account ID
siteIdstringYes—The site ID configured in your account
channelIdstringYes—The channel ID for this placement context
locationIdstringYes—The location ID for this placement context
userIdstringNoundefinedAn identifier for the current user, used for personalization
ipstringYes—The IP address of the end user
userAgentstringYes—The user-agent string of the end user's browser
urlstringYes—The absolute URL of the page being rendered
loggingbooleanNofalseEnable debug logging to the console
overridesobjectNoundefinedReplace the SDK's internal services or supply dependencies yourself. See Overrides

Account identifiers​

accountId, siteId, channelId, and locationId are all required and all come from your Surfside account setup.

FieldWhat it identifies
accountIdYour Surfside account. Fixed for your organization.
siteIdThe property being monetized. Determines which publisher configuration is loaded — product card templates, clickthrough templates, category mappings, and site settings.
channelIdThe surface the placement lives on, so the same site can distinguish web, app, kiosk, and so on.
locationIdThe store, dispensary, menu, or location context the page represents. Used for targeting and for the sponsored product feed.

accountId and siteId together resolve your publisher configuration. If either is wrong, initialization fails with a MissingConfigurationError that includes a link to the matching configuration in the Surfside app.

locationId is checked against the excludedLocations list in your publisher configuration. If the location is excluded, initialization fails with a LocationExcludedError — this is a deliberate configuration outcome, not a fault. Treat it as "no ads for this location" and render your unsponsored experience.

Request context: ip, userAgent, and url​

These three fields are the main difference between headless and browser integrations. In a browser, the SDK reads them automatically from window and navigator. On a server there is no browser context, so you forward them from the incoming request.

ip:        req.ip,
userAgent: req.headers['user-agent'] ?? '',
url: req.headers.referer ?? `${req.protocol}://${req.hostname}${req.originalUrl}`,

url must be an absolute, parseable URL. It is parsed into the page context the SDK uses for macro expansion in creative and clickthrough templates, and it is sent as the site context on the bid request. A relative path or a malformed string fails initialization with a NotAUrlError.

Use the URL of the page the user is viewing, not the URL of your API endpoint. If your ad service is called from a rendering server, forward the page URL explicitly — req.headers.referer is a reasonable fallback but is not always present.

userAgent drives two things:

  1. Device detection. The SDK parses make, model, OS, OS version, browser, and CPU architecture out of the string and puts them on the OpenRTB device object. A missing or generic user-agent means the bidder sees an unknown device, which reduces the pool of bids that can match.
  2. Bot filtering. See Bot filtering below.

ip is the end user's IP address. See Geo resolution in headless for exactly how it is used today and how to control geo targeting from a server.

userId​

An identifier for the current end user, used for personalization. It should be stable for a given user across requests — a hashed customer ID or a loyalty ID works well.

userId unlocks the personalized recommenders. Without it:

  • generateForYou falls back to top products
  • generatePreviousPurchases falls back to top products

Sponsored product carousels and banner/video creatives work the same either way. Omit userId entirely for anonymous traffic rather than passing an empty string or a placeholder.

logging​

Set logging: true to have the SDK write its initialization and request activity to the console — configuration loading, geo resolution, service wiring, bid activity, and template evaluation.

const adsRes = await LoadSurfAdsCoreHeadless({
// ...identifiers and request context
logging: true,
});

Output is prefixed with [SURF] and is plain-text (not the coloured browser format), so it is safe to pipe into a log aggregator. It is verbose — use it in development and when diagnosing an integration, not in steady-state production.

Defaults applied for you​

Because there is no browser to read from, LoadSurfAdsCoreHeadless fills in a small number of values on your behalf. These are fixed and cannot be set through the headless config:

ValueFixed toEffect
Screen size1920 × 1080Reported as the device's screen dimensions on the bid request
Mobile flagfalseThe device is always described as non-mobile
Languageen-USReported as the device language on the bid request

If your integration serves a materially mobile audience, or a non-English one, and you need the bid request to reflect that, supply the device object yourself through overrides.dependencies.device. See Overrides.

Configuration caching​

Your publisher configuration and the global category configuration are fetched over the network the first time you initialize, then cached in-process for 5 minutes:

  • The publisher configuration cache is keyed on accountId + siteId, so a multi-tenant server caches each account separately.
  • The category configuration is global and shared across all accounts in the process.

This means initialization is cheap after the first call, and it means changes you make in the Surfside app can take up to 5 minutes to appear on a long-running server. Restart the process to pick them up immediately.

The caches live in module scope, so they are shared by every LoadSurfAdsCoreHeadless call in the same process and are not shared across processes or instances. If you want different caching behaviour — a shared Redis cache across your fleet, or no caching at all in a test environment — replace the loaders through overrides.services.

Geo resolution in headless​

Geo is resolved by calling Surfside's geo enrichment service during initialization, and the result populates device.geo and device.ip on every bid request built from the returned API object.

In a headless integration that call is made from your server, so it resolves your server's network location rather than the end user's. On a single-region deployment, every request will geo-target to your datacenter.

Geo is not required for bidding — it is a targeting signal — so a failed or absent geo lookup does not fail initialization. It does reduce the number of bids that match.

If geo-targeting accuracy matters for your integration, supply the geo context yourself with overrides.dependencies.geo. The SDK will use exactly what you pass and skip the lookup entirely:

import { LoadSurfAdsCoreHeadless } from '@surfside/ads-core';

const adsRes = await LoadSurfAdsCoreHeadless({
accountId: 'your-account-id',
siteId: 'your-site-id',
channelId: 'your-channel-id',
locationId: 'your-location-id',
ip: req.ip,
userAgent: req.headers['user-agent'] ?? '',
url: pageUrl,
overrides: {
dependencies: {
geo: {
ip: req.ip,
country: 'US',
region: 'MA',
city: 'Boston',
zip: '02110',
coords: { latitude: 42.3554, longitude: -71.0605, accuracy: 50 },
utcoffset: -300,
},
},
},
});

Most CDNs and load balancers give you these values on the request already — Cloudflare exposes cf-ipcountry and friends, AWS CloudFront exposes CloudFront-Viewer-Country and CloudFront-Viewer-City, and Vercel exposes x-vercel-ip-* headers. Mapping those onto the geo object is usually a few lines and gives you per-user accuracy without an extra network call.

Talk to your Surfside contact if you want geo resolved server-side by Surfside instead.

Bot filtering​

The SDK checks userAgent against a list of known crawlers, scrapers, and monitoring agents — Googlebot, bingbot, Ahrefs, Screaming Frog, security scanners, and a long tail of AI crawlers among them.

When the user-agent matches, the SDK does not send a bid request or call the recommender at all. Instead:

CallBehaviour for a matched bot
requestBidsReturns Err(NoBidsError) without a network call
getRecommendedProductReturns Err(NoRecommendationsError)
generateTopProductsYields nothing
generateForYouYields nothing
generatePreviousPurchasesYields nothing

Initialization itself still succeeds — the filtering happens at request time, not init time. This keeps crawler traffic out of your bid volume and your measurement, and it means SEO crawls of your pages will render without ads.

If you are testing your integration with curl or a synthetic monitor and consistently see no bids, check the user-agent you are sending first.

Overrides​

overrides is the escape hatch. It lets you replace the SDK's internal services or hand it dependencies you have already resolved. Most integrations never need it; it exists for local caching, mock services in test environments, and custom data sources.

There are two kinds.

overrides.dependencies​

Supply an already-resolved value and the SDK skips the work of producing it.

KeyTypeUse it to
configurationICompiledPublisherConfigurationSupply a publisher configuration directly, bypassing the network fetch and the compile step entirely
geoIGeoContextSupply geo context from your edge or CDN. See Geo resolution in headless
deviceIDeviceSupply an OpenRTB device object, overriding the defaults for screen size, mobile flag, and language
handlebarstypeof HandlebarsSupply a preconfigured Handlebars runtime with your own helpers registered
loggerILoggerCurrently unused by the headless entry point — use the logging flag instead

overrides.services​

Replace a function the SDK would otherwise call. Each entry is an override factory: a function that receives the current config and the SDK's original implementation, and returns a Result containing the replacement.

type OverrideFactory<T> = (config, original: T) => Result<T>;

Wrapping original rather than replacing it outright is usually what you want — that way you inherit the SDK's behaviour and add to it. If your factory returns an error Result, the SDK logs a warning and silently falls back to the original implementation, so a broken override degrades rather than breaks.

KeyReplaces
loadConfigurationFileFetching the publisher configuration file for an account and site
loadGlobalCategoryConfigurationFetching the global category configuration
requestBidsSending the bid request to the Surfside bidder
startRequestThe OpenRTB 2.6 bid request builder
mapCategoriesMapping your categories to Surfside canonical categories
getAllSponsoredProductsResolving sponsored products from a native creative ID
getAllSponsoredBrandProductsResolving sponsored brand products from a native creative ID
getRecommendedProductResolving product detail from a product ID
generateTopProductsThe top-products recommender
generateForYouThe personalized recommender
generatePreviousPurchasesThe previous-purchases recommender
getDynamicZoneFetching dynamic zone definitions
buildAllSponsoredProductCardResponsesBuilding product card responses from a bid response
generateSponsoredProductCardResponseGenerating a single product card response
evaluateProductCardTemplateHandlebars evaluation of the product card template
evaluateClickthroughTemplateHandlebars evaluation of the clickthrough URL template
trackerServiceThe impression and click tracker service
createTelemetryCollectorThe telemetry collector
generateCategoriesDeprecated. Use mapCategories

Example: serving product data from your own catalogue​

By default the SDK fetches product detail from the Surfside product feed. If you already have a product catalogue in-process, wrap the original and serve from it:

const adsRes = await LoadSurfAdsCoreHeadless({
// ...identifiers and request context
overrides: {
services: {
getRecommendedProduct: (config, original) => Ok(
async (productId: string) => {
const local = await catalogue.find(productId);
return local !== undefined
? Ok(local)
: original(productId); // fall back to the Surfside feed
}
),
},
},
});

Example: sharing the configuration cache across your fleet​

The built-in configuration cache is per-process. On a fleet of instances that means each one fetches its own copy. Point the loader at a shared cache instead:

overrides: {
services: {
loadConfigurationFile: (config, original) => Ok(
async (accountId: string, siteId: string) => {
const key = `surfside:config:${accountId}:${siteId}`;
const cached = await redis.get(key);
if (cached !== null) {
return Ok(JSON.parse(cached));
}
const res = await original(accountId, siteId);
if (res.ok) {
await redis.set(key, JSON.stringify(res.value), 'EX', 300);
}
return res;
}
),
},
}

The Result pattern​

LoadSurfAdsCoreHeadless returns a Result. Before using the returned value, always check whether the call succeeded:

const adsRes = await LoadSurfAdsCoreHeadless({ /* ... */ });

if (!adsRes.ok) {
console.error(adsRes.error); // handle the error however you want
return;
}

const ads = adsRes.value;

A Result is a discriminated union, so checking .ok narrows the type for you:

type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };

Every async function in the Surfside SDK follows this pattern and returns a Result rather than throwing. Network failures, malformed responses, and configuration problems all arrive as values you can branch on. You do not need to wrap SDK calls in try/catch.

Initialization errors​

ErrorCauseWhat to do
NotAUrlErrorurl was not a parseable absolute URLFix the URL you're forwarding. Check for relative paths and empty strings
MissingConfigurationErrorNo publisher configuration exists for that accountId + siteIdCheck the identifiers. The error message includes a direct link to the configuration in the Surfside app
SurfsideAdsDisabledErrorAds are disabled for this site in your publisher configurationExpected when ads are intentionally turned off. Render your unsponsored experience
LocationExcludedErrorlocationId is in the site's excluded locations listExpected for excluded locations. Render your unsponsored experience

SurfsideAdsDisabledError and LocationExcludedError are configuration outcomes rather than faults. Serving a 503 for them will make your monitoring noisy — handle them as "no ads here" and move on. A useful shape:

import {
LoadSurfAdsCoreHeadless,
SurfsideAdsDisabledError,
LocationExcludedError,
} from '@surfside/ads-core';

const adsRes = await LoadSurfAdsCoreHeadless({ /* ... */ });

if (!adsRes.ok) {
const expected =
adsRes.error instanceof SurfsideAdsDisabledError ||
adsRes.error instanceof LocationExcludedError;

if (expected) {
return res.status(204).send(); // no ads for this context
}

logger.error('Surfside init failed', adsRes.error);
return res.status(503).json({ error: adsRes.error.message });
}

A failed geo lookup does not fail initialization — see Geo resolution in headless.

What you get back​

The object in adsRes.value is a collection of functions already configured with your account-specific data. You don't pass accountId, siteId, or the other identifiers on every call; they're baked in.

const ads = adsRes.value;

// These are ready to use, no extra config needed
const bids = await ads.requestBids(request);
const banner = ads.buildBannerResponseCore(bid);

This is why you call a function to get a bunch of functions: LoadSurfAdsCoreHeadless does the setup work once — loading configuration, resolving geo context, wiring services together — and returns an object ready to use for the lifetime of that request.

Building and sending bid requests​

MemberSignatureDescription
startRequest(zoneId: string)Starts an OpenRTB 2.6 bid request builder. See Building Bid Requests
startNative()Starts an empty OpenRTB Native 1.2 request builder
defaultNative()A native request builder pre-filled with the standard Surfside product card assets
requestBids(request: IBidRequest)Sends a bid request and returns the bid response

Building creatives from a bid response​

MemberSignatureDescription
buildBannerResponseCore(bid: IBid)Builds a single banner from a bid
buildVideoResponseCore(bid: IBid)Builds a single video (VAST) from a bid
buildAllBannersCore(bidResponse: IBidResponse)Builds every banner in a bid response
buildAllVideosCore(bidResponse: IBidResponse)Builds every video in a bid response
buildAllResponsesCore(bidResponse: IBidResponse)Builds every creative of every type in a bid response
buildAllSponsoredProductCardResponses(bidResponse: IBidResponse)Builds every sponsored product card in a bid response
generateSponsoredProductCardResponse(bid: IBid)Builds a single sponsored product card

Carousels​

Carousels are lazy. Each builder returns a state object with a getProduct() async generator; products are fetched as you consume them, and the carousel re-requests bids when it runs out. Take only as many as you intend to render.

MemberSignatureDescription
buildSponsoredCarousel(request: IBidRequest)Sponsored products only, refilling from the bidder as it is consumed
buildRecommendedCarousel(recommender: PublicRecommenderType, category?: string)Recommended products only
buildHybridCarousel(recommender: PublicRecommenderType, request: IBidRequest)Leads with sponsored products, fills with recommendations
buildSimpleCarousel(bid: IBid)A flat, non-refilling carousel built from a single bid you already have
buildSponsoredCarouselFromInitialBid(request: IBidRequest, initialBid: IBid)Sponsored carousel seeded with a bid you already have — used by dynamic zones
buildHybridCarouselFromInitialBid(recommender, request, initialBid)Hybrid carousel seeded with a bid you already have — used by dynamic zones
buildAllSimpleCarousels(bidResponse: IBidResponse)Every simple carousel in a bid response, built at once

PublicRecommenderType is an object with a single type field: { type: 'top-products' }, { type: 'for-you' }, or { type: 'past-purchases' }.

See Carousels.

Recommendations and product data​

MemberSignatureDescription
generateTopProducts(category?: string)Async generator of the most popular products on the site
generateForYou(category?: string)Async generator personalized to the userId you initialized with, falling back to top products
generatePreviousPurchases(category?: string)Async generator based on the user's purchase history, falling back to top products
getAllSponsoredProducts(nativeId: string, categories?: string[])Resolves the products behind a native creative ID
getRecommendedProduct(productId: string)Resolves product detail for a product ID

The userId you passed at initialization is baked into the personalized recommenders — you do not pass it again per call. All three are async generators, so they are consumed with for await, and all three are subject to bot filtering.

Dynamic zones​

MemberSignatureDescription
getDynamicZone(zoneId: string)Fetches a dynamic zone definition
buildDynamicZoneRequest(zoneId: string, zone: IDynamicZone, categories?: string[])Builds the bid request for a dynamic zone

Context, configuration, and tracking​

MemberSignatureDescription
configurationpropertyThe compiled publisher configuration for this site
getConfiguration()The same configuration, as a Result
geopropertyThe resolved geo context, or undefined
getGeo()The same geo context, as a Result
devicepropertyThe resolved OpenRTB device object, or undefined
getDevice()The same device object, as a Result
mapCategories(categories: string[])Maps your categories to Surfside canonical categories
generateCategories(categories: string[])Deprecated. Alias of mapCategories
evaluateProductCardTemplate(product, clickthrough?)Renders the site's product card template
evaluateClickthroughTemplate(product)Renders the site's clickthrough URL template
trackerspropertyService that builds impression and click trackers for a product
handlebarspropertyThe Handlebars runtime used for template evaluation
loggerpropertyThe logger in use, if logging was enabled

configuration, geo, and device are plain properties resolved at initialization; the get* variants return the same data wrapped in a Result and exist so that the same code path works in browser and headless integrations.

Lifecycle​

Initialize once per request, not once per process. The returned object captures request-scoped context — the geo, device, user, and page URL for that specific visitor. Holding one across requests will attribute one user's context to another's ads and measurement.

Initializing per request is cheap. The two network calls that initialization makes — publisher configuration and category configuration — are cached in-process for 5 minutes, so in steady state initialization does no network I/O at all and costs roughly a Handlebars compile.

Within a single request, initialize once and reuse the object for every placement on the page. A single bid request can carry every impression opportunity at once, so one initialization typically serves the whole page.

Complete example​

An Express handler serving a banner, with the full initialization path:

import express from 'express';
import {
LoadSurfAdsCoreHeadless,
SurfsideAdsDisabledError,
LocationExcludedError,
} from '@surfside/ads-core';

const app = express();

app.get('/ads/banner/:zoneId', async (req, res) => {
const { zoneId } = req.params;
const { accountId, siteId, channelId, locationId } = req.query as Record<string, string>;

const adsRes = await LoadSurfAdsCoreHeadless({
accountId,
siteId,
channelId,
locationId,
userId: req.query.userId as string | undefined,
ip: req.ip ?? '127.0.0.1',
userAgent: req.headers['user-agent'] ?? '',
url: req.headers.referer ?? `${req.protocol}://${req.hostname}${req.originalUrl}`,
logging: process.env.NODE_ENV !== 'production',
});

if (!adsRes.ok) {
const expected =
adsRes.error instanceof SurfsideAdsDisabledError ||
adsRes.error instanceof LocationExcludedError;

return expected
? res.status(204).send()
: res.status(503).json({ error: adsRes.error.message });
}

const ads = adsRes.value;

const bidsRes = await ads.requestBids(
ads.startRequest(zoneId).withBanner(4, 1).build()
);
if (!bidsRes.ok) {
return res.status(204).send(); // no bids, or the request was from a bot
}

const bid = bidsRes.value.seatbid?.[0]?.bid?.[0];
if (bid === undefined) {
return res.status(204).send();
}

const bannerRes = ads.buildBannerResponseCore(bid);
if (!bannerRes.ok) {
return res.status(204).send();
}

res.setHeader('Content-Type', 'text/html');
res.send(bannerRes.value.adm);
});

app.listen(3000);

Next steps​

  • Building Bid Requests — cover every placement on a page in one request
  • Banners — request options and response fields for banner creatives
  • Videos — VAST responses
  • Carousels — sponsored, recommended, and hybrid product carousels