Implementing Videos
- Headless
- React Components
- React Hooks
- Web Components
This section covers the request options and response fields specific to video creatives. For how to build and send a bid request, see Building Bid Requests.
Requesting a video
Use .andVideo(width, height, minDuration, maxDuration) on an impression to ask for a video. Duration values are in seconds. A request typically covers more than the video slot alone — here, a video alongside a banner:
/* const ads = await LoadSurfAdsCoreHeadless({ ... }) — see Initialization */
const request = ads.startRequest('your-zone-id')
.withImp('video-zone-id')
.andVideo(1920, 1080, 0, 30)
.withImp('banner-zone-id')
.andBanner(4, 1)
.build();
const bidsRes = await ads.requestBids(request);
if (!bidsRes.ok) {
console.error(bidsRes.error);
return;
}
You can also pass categories and keywords for targeting before calling .withImp():
const request = ads.startRequest('your-zone-id')
.withSurfCategory(['grocery', 'beverages'])
.withKeywords(['organic', 'sale'])
.withImp('video-zone-id')
.andVideo(1920, 1080, 0, 30)
.withImp('banner-zone-id')
.andBanner(4, 1)
.build();
Building video responses
buildAllVideosCore takes the full bid response and returns a Result containing an array of built videos, pulling out just the video creatives even if the request also asked for other creative types. Each video includes a video field containing the VAST XML string, and a clickthrough URL.
const videosRes = ads.buildAllVideosCore(bidsRes.value);
if (!videosRes.ok) {
console.error(videosRes.error);
return;
}
If the request mixed creative types on the same impression, use buildAllResponsesCore instead (see Building Bid Requests) and switch on creative.type === 'video'.
Writing the output
video.video is the VAST XML string. Write it to the response for your video player to consume:
for (const video of videosRes.value) {
res.write(video.video);
}
Win notification
Each video also has a win field — a URL that must be called immediately before the creative renders on the page. This is how Surfside tracks impressions, and it should fire right before the video is rendered, not when the server generates the response and not after the player has already rendered.
The simplest approach is to embed it as a 1×1 tracking pixel alongside the player:
for (const video of videosRes.value) {
res.write(`${yourRenderVideoFunction(video.video)}<img src="${video.win}" width="1" height="1" style="display:none">`);
}
Alternatively, fire it with a fetch call from the client side immediately before the player is inserted:
fetch(video.win);
There is no meaningful response, so you do not need to await it or handle errors.
Viewability tracking
Each video also has a viewable field — a URL that must be called when the creative meets viewability (50% viewable for 2 seconds). This is separate from the win notification: win confirms the ad was served and rendered, while viewable confirms it was actually seen by the user.
Because viewability depends on scroll position and dwell time, it's typically fired from an IntersectionObserver watching the rendered player's container, rather than embedded as a static pixel. The Headless workflow doesn't render the player for you, and there's no way to predict how any given third-party video player structures its DOM — adapt the selector below to whatever wrapper element your player renders into:
// Adapt this for your framework and video player
const videoElement = document.querySelector('#some-video-player');
let timer;
let hasFired = false;
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio > 0.5 && !hasFired) {
timer = setTimeout(() => {
navigator.sendBeacon(video.viewable);
hasFired = true;
}, 2_000); // dwell time, 2 seconds
} else {
clearTimeout(timer);
timer = undefined;
}
});
}, { threshold: 0.5 }); // 50% viewable
observer.observe(videoElement);
navigator.sendBeacon is preferable to fetch here since it's more likely to complete even if the user navigates away right as the timer fires. As with win, there is no meaningful response, so you do not need to handle errors.
SurfsideVideo renders a VAST video ad for a given zone. It sends the bid request, receives the VAST response, and initializes a video player in the DOM automatically.
import { SurfsideVideo } from '@surfside/ads-react';
<SurfsideVideo
zoneId="your-zone-id"
size={[16, 9]}
maxDuration={30}
/>
Requires a SurfsideProvider2 ancestor.
Props
| Prop | Type | Required | Description |
|---|---|---|---|
zoneId | string | Yes | The zone ID of the placement |
size | [number, number] | Yes | The requested video size as an aspect ratio [width, height] — e.g. [16, 9], [4, 3] |
maxDuration | number | Yes | Maximum acceptable video duration in seconds |
minDuration | number | No | Minimum acceptable video duration in seconds. Default 0 |
mimes | string[] | No | Accepted MIME types. Default ['video/mp4']. Always include video/mp4 if you specify this |
category | string[] | No | Category targeting hints passed to the bidder |
enabled | boolean | No | When false, the component renders nothing. Default true |
onError | (error: ErrResult) => void | No | Called when an error occurs or no bid is returned |
Size and duration
size is an aspect ratio. The player fills the width of its container. maxDuration is required — without it the bidder has no basis for selecting a creative.
// 16:9 pre-roll, up to 30 seconds
<SurfsideVideo zoneId="preroll" size={[16, 9]} maxDuration={30} />
// 4:3, 5–15 seconds
<SurfsideVideo zoneId="mid-page" size={[4, 3]} minDuration={5} maxDuration={15} />
Category targeting
Pass category to narrow which creatives are eligible:
<SurfsideVideo
zoneId="your-zone-id"
size={[16, 9]}
maxDuration={30}
category={['grocery', 'beverages']}
/>
Error handling
onError fires when no bid is returned or when the player fails to initialize. Use it to log the condition or swap in a fallback:
<SurfsideVideo
zoneId="your-zone-id"
size={[16, 9]}
maxDuration={30}
onError={(err) => console.warn('No video filled', err.error)}
/>
When there is no bid, the component renders an empty <div> and fires onError. There is no built-in fallback UI for video — size the container with CSS if you need to hold space while loading.
Disabling a placement
Set enabled={false} to suppress the placement without removing the component:
<SurfsideVideo
zoneId="your-zone-id"
size={[16, 9]}
maxDuration={30}
enabled={hasConsent}
/>
useSurfsideVideo fetches a video creative and returns the video URL, VAST markup, analytics callbacks, and loading state. Use it when you need direct control over video rendering rather than the SurfsideVideo component.
import { useLayoutEffect } from 'react';
import { useSurfsideVideo } from '@surfside/ads-react';
const { video, loading, error, analytics } = useSurfsideVideo({
zoneId: 'your-zone-id',
size: [16, 9],
maxDuration: 30
});
useLayoutEffect(() => {
analytics?.impression();
analytics?.win();
}, [analytics]);
if (loading) {
return <div>Loading...</div>;
}
if (error !== undefined) {
throw error.error;
}
return (
<video autoPlay muted>
<source src={video.videoUrl} type={video.mime} />
</video>
);
Requires a SurfsideProvider2 ancestor.
Options
| Option | Type | Required | Description |
|---|---|---|---|
zoneId | string | Yes | Zone ID of the placement |
size | [number, number] | Yes | Requested aspect ratio [width, height] — e.g. [16, 9], [4, 3] |
maxDuration | number | Yes | Maximum video duration in seconds |
minDuration | number | No | Minimum video duration in seconds. Default 0 |
mimes | string[] | No | Accepted MIME types. Default ['video/mp4']. Always include video/mp4 if you override this |
category | string[] | No | Category targeting hints passed to the bidder |
enabled | boolean | No | When false, no request is made. Default true |
Return values
| Field | Type | Description |
|---|---|---|
video | IVideo \| undefined | The video creative. Defined only on success |
loading | boolean | true while the bid request is in flight |
error | ErrResult \| undefined | Set when an error occurs or no bids are returned |
analytics | ISurfsideVideoAnalytics \| undefined | Playback event callbacks. Defined only on success |
refetch | () => Promise<void> | Re-runs the bid request using the same options |
Rendering the video
video.videoUrl is the URL of the video file and video.mime is its MIME type. Render them with a <video> tag:
<video autoPlay muted>
<source src={video.videoUrl} type={video.mime} />
</video>
Modern browsers block videos from autoplaying unless they are muted. Use autoPlay muted unless you have a specific reason not to.
Analytics callbacks
The analytics object exposes callbacks for standard VAST playback events. Call each one at the appropriate moment during playback:
| Callback | When to call |
|---|---|
win() | Immediately before the video renders on the page |
impression() | Immediately before the video renders on the page |
start() | When playback begins |
firstQuartile() | When 25% of the video has played |
midpoint() | When 50% of the video has played |
thirdQuartile() | When 75% of the video has played |
complete() | When the video finishes playing |
pause() | When the user pauses the video |
Call win and impression immediately before the creative renders — the SDK's win contract requires these calls to fire before the video is painted, not after. Use useLayoutEffect rather than useEffect: useLayoutEffect runs synchronously after the DOM is updated but before the browser paints, while useEffect runs after paint and would fire these calls too late.
useLayoutEffect(() => {
analytics?.impression();
analytics?.win();
}, [analytics]);
For playback events, attach handlers to the <video> element's events. Quartile events require tracking the current playback time:
const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
const el = e.currentTarget;
const pct = el.currentTime / el.duration;
if (pct >= 0.25 && !firedFirstQuartile) {
analytics?.firstQuartile();
setFiredFirstQuartile(true);
}
// ... repeat for midpoint (0.5) and thirdQuartile (0.75)
};
<video autoPlay muted onTimeUpdate={handleTimeUpdate} onEnded={() => analytics?.complete()}>
<source src={video.videoUrl} type={video.mime} />
</video>
Each quartile event must fire exactly once, so track whether it has been fired already.
<surf-video> renders a video ad for a given zone. Like the React component, it handles the bid request, VAST rendering, and win tracking internally — you just place the element on the page.
<surf-video
account-id="your-account-id"
site-id="your-site-id"
channel-id="your-channel-id"
location-id="your-location-id"
zone-id="your-zone-id"
width="16"
height="9"
min-duration="0"
max-duration="30"
></surf-video>
Requires the Surfside script tag — see Initialization.
Attributes
| Attribute | Required | Description |
|---|---|---|
account-id, site-id, channel-id, location-id | Yes | Your Surfside account identifiers — see Initialization |
zone-id | Yes | The zone ID of the placement |
width | Yes | The requested video width, as part of an aspect ratio — e.g. width="16" |
height | Yes | The requested video height, as part of an aspect ratio — e.g. height="9" |
min-duration | Yes | Minimum acceptable video duration in seconds. Unlike the React workflows, there's no default — you must set it explicitly |
max-duration | Yes | Maximum acceptable video duration in seconds |
category | No | Comma-separated category targeting hints passed to the bidder |
keywords | No | Comma-separated keyword targeting hints passed to the bidder |
Size and duration
width and height express an aspect ratio, the same convention as the other workflows. Both min-duration and max-duration are required attributes — without them the element won't have enough information to request a bid.
Updating a placement
Attributes are reactive. Changing zone-id, max-duration, or any other attribute on an already-connected <surf-video> from JavaScript automatically triggers a new bid request and re-renders the placement.
No fill
If no bid fills the placement, the component renders nothing. There's no error event to listen for.