Publisher Documentation

Everything you need to integrate PopAds — from signup to creator payouts on Solana.

Contents

Getting Started

PopAds is a Solana-native video advertising network. As a publisher you serve ads through our SDK, accrue USDC revenue per impression, and forward payments to your creators directly from your dashboard or API. The whole loop runs on Solana with no token launch and no on-chain custody for your creators.

Integration model

Your creator's relationship is with you, not with PopAds. Creators never sign anything for us — they click "Withdraw" inside your product, your backend calls the PopAds API, and we dispatch USDC straight from our treasury to the creator's Solana wallet.

Six steps to first revenue

  1. 1

    Sign up & connect a Solana wallet

    Visit app.popadsnetwork.com/signup, choose Publisher, and connect your wallet via Dynamic. The wallet you connect at signup is your identity wallet — only it can authorise API key issuance later. Use a hardware-backed wallet if possible.

  2. 2

    Wait for approval

    PopAds reviews new publisher applications manually. You'll start in pending status until an admin marks you active.

  3. 3

    Create at least one placement

    In the dashboard, go to Placements → New Placement. Each placement gets a permanent key like pop-7f3bafe6 and a tag URL. See Creating Placements.

  4. 4

    Install the SDK and serve ads

    For video, drop the SDK script tag and call PopAds.init(). For display banners, drop a single script tag. See SDK Installation.

  5. 5

    Generate an API key (optional)

    Only needed if you want to read stats programmatically or pay creators out via API. Dashboard → API Access → sign delegation message → copy key. See API Keys.

  6. 6

    Wire up creator payouts

    When a creator clicks "Withdraw" on your platform, your backend calls POST /api/publisher/v1/payout and we dispatch USDC to the creator's wallet on Solana. See Creator Payouts.

Solana addresses are case-sensitive

Never lowercase a Solana wallet address. Treat them as opaque strings.

Creating Placements

A placement is one slot of inventory on your platform — one row in PopAds with a permanent key, a fixed format, and its own configuration. One placement = one tag URL; tags don't compose, merge, or include each other. To run multiple ad types on the same page, create one placement per type and wire each into your page separately.

Format Tag type What it serves SDK parameter
video_session VMAP Every in-playback ad break in one VMAP: pre-roll, mid-rolls, post-roll, and image overlays — each independently toggleable per placement vmapUrl
pause_screen VAST Renders when the viewer pauses, hides on resume (HBO Max / Hulu pattern) pauseAdUrl
display JS tag Display banner image, injected via <script> tag — (no SDK; standalone tag)

Pick the right format for what you want

If you want… Create this
Pre-roll + overlay during the same videoOne video_session placement with pre-roll on and Max overlays > 0. Both ad types come back in the single VMAP — don't make two placements for this.
Just an overlay banner during videos (no pre-roll)One video_session placement with pre-roll off, post-roll off, no mid-rolls, and Max overlays > 0. The VMAP only emits NonLinear overlay breaks.
Just a pre-roll (no overlays)One video_session placement with pre-roll on and Max overlays set to 0.
Pause-screen ads on top of videosOne pause_screen placement, separate from the in-playback one. You'll pass it to the SDK as pauseAdUrl alongside vmapUrl.
Banner ads on article / index pages (no video)One display placement per banner slot, with banner_size matching the slot dimensions.
Pre-roll + overlay + pause + a homepage bannerThree placements: one video_session (covers pre-roll and overlay), one pause_screen, one display. Wire all three into the relevant pages.

Format and key are immutable

Once a placement is created its format and placement_key can't change — they're embedded in tag URLs already in production. A video_session placement can't be turned into pause-screen inventory or a display banner. Within video_session you can toggle pre-roll / mid-rolls / post-roll / overlays freely at any time — those are settings, not format changes.

If you picked the wrong format: create a new placement with the correct format, swap the URL on your page, then delete the old one (the dashboard refuses to delete placements that already have billed impressions — pause those instead so payout history stays intact).

Tag URL formats

After creating a placement you'll see a tag URL in the dashboard. The shape depends on the format:

# video_session → VMAP
https://app.popadsnetwork.com/api/vmap/pop-7f3bafe6

# pause_screen → VAST with t=pause
https://app.popadsnetwork.com/api/vast/pop-7f3bafe6?t=pause

# display → JS tag (drop in a <script src="…">)
https://app.popadsnetwork.com/api/tag/pop-7f3bafe6

One placement key = one inventory surface

There's no way to combine the contents of two placement keys into a single tag — but you rarely need to. A video_session placement already covers every in-playback break type (pre-roll, mid-rolls, post-roll, overlays) from one VMAP URL. The only inventory surfaces that require separate placement keys are pause-screen ads (different player event) and display banners (no player at all).

Worked example: a video page with all four ad types

Say you want pre-roll, mid-rolls, post-roll, in-stream overlay, pause-screen, and a 728×90 banner above the player. Here's the full publisher-side wiring.

Step 1 — Create three placements on the dashboard

Placement name Format Example key
Watch Page Videovideo_sessionpop-VIDEO123
Watch Page Pausepause_screenpop-PAUSE456
Watch Page Top Bannerdisplay (728×90)pop-BANNER789

Step 2 — Drop the SDK + display tag in your page

<!-- Loaded once per page -->
<script src="https://app.popadsnetwork.com/popads-sdk.js" async></script>

<!-- 728×90 banner above the player. Standalone, no SDK needed. -->
<script
  src="https://app.popadsnetwork.com/api/tag/pop-BANNER789?creator_id=42&content_id=video-abc"
  data-refresh="60"
  async>
</script>

<div id="player-wrap">
  <video id="my-video" controls></video>
</div>

Step 3 — One PopAds.init() call wires the video + pause inventory together

PopAds.init({
  playerInstance:  player,                                                                // your player object
  playerType:      'bitmovin',                                                            // 'bitmovin' | 'videojs' | 'jwplayer' | 'html5'
  playerContainer: document.getElementById('player-wrap'),

  vmapUrl:         'https://app.popadsnetwork.com/api/vmap/pop-VIDEO123',                 // video_session: pre/mid/post + overlay
  pauseAdUrl:      'https://app.popadsnetwork.com/api/vast/pop-PAUSE456?t=pause',         // pause_screen

  overlayPosition: 'top-right',                                                           // for the overlay non-linears in vmapUrl
  pauseMinSeconds: 3,                                                                     // debounce; ignore quick scrubs

  contentId:       videoId,                                                               // optional attribution — see Creator Attribution
  creatorId:       creatorUuid,
  creatorWallet:   creatorSolanaAddress,

  viewerWallet:    solanaWalletAddress,                                                   // optional — logged-in viewer's wallet, unlocks Audience Insights
  viewerExternalId: hashedUserId,                                                         // optional — for non-wallet auth; hash before send
})

What serves where

  • Pre-roll, mid-rolls, post-roll, in-stream overlay — all from vmapUrl (the video_session placement). Single VMAP, all four break types.
  • Pause-screen ad — from pauseAdUrl (the pause_screen placement). Suppressed automatically while a linear ad is playing.
  • 728×90 above the player — from the display tag <script>. Independent lifecycle from the SDK; the page can have as many of these as it wants in different slots.

Notice there's no separate overlayVmapUrl — overlays come from the same vmapUrl as the linear breaks. If you only want overlays (no pre-roll), keep the same video_session placement and just untick "Pre-roll" / "Post-roll" and leave mid-rolls empty in placement settings. The VMAP will emit only the NonLinear overlay breaks; same URL, same SDK call.

SDK Installation

The PopAds SDK is a small (~12 KB) script that handles VAST 4.2 + VMAP scheduling for your video player. It supports Bitmovin, Video.js, JW Player, and HTML5 native video out of the box.

1. Load the SDK

Drop this once per page, before your player code runs:

<script src="https://app.popadsnetwork.com/popads-sdk.js" async></script>

2. Initialise after your player loads

PopAds.init({
  playerInstance:  player,                                        // your player object
  playerType:      'bitmovin',                                    // 'bitmovin' | 'videojs' | 'jwplayer' | 'html5'
  playerContainer: document.getElementById('player-wrap'),        // DOM node containing the <video>
  vmapUrl:         'https://app.popadsnetwork.com/api/vmap/pop-XXXXXXXX',
  contentId:       videoId,           // optional — per-content attribution
  creatorId:       creatorUuid,       // recommended — per-creator attribution
  creatorWallet:   creatorSolanaAddr, // optional — helps with payouts
  viewerWallet:    solanaWalletAddress, // optional — viewer's wallet; unlocks Audience Insights (see Audience Insights)
  viewerExternalId: hashedUserId,     // optional — for non-wallet auth; hash internal user ID server-side
  contentType:     'long_form',       // 'short_form' | 'long_form' | 'live' — drives Better Ads + mid-rolls
  contentDuration: video.duration,    // seconds — enables dynamic mid-roll cadence on long-form
  mutedOnStart:    true,              // default true; in-player unmute button shown to viewer
  overlayPosition: 'top-right',
})

Why creator_id matters

Passing creatorId at impression time lets you query per-creator revenue and dispatch payouts proportionally later. Skipping it means you can only pay flat amounts. See Creator Attribution.

Why viewerWallet matters

Passing the logged-in viewer's wallet (or a hashed external ID for non-wallet auth) at impression time unlocks an audience-quality view no cookie-based DSP can match — wallet age, balance, top holdings. Advertisers pay materially more for inventory where they can see who actually saw the ad. See Audience Insights.

Why contentType matters

Coalition for Better Ads gates ad behaviour on content length. Short-form (clips, < 8 min) gets a skippable pre-roll and no mid-rolls. Long-form (movies, episodes) unlocks dynamic mid-rolls — typically 5× the per-session revenue of short-form. If you omit contentType the server infers from duration, but explicit is faster and deterministic. See Content & Compliance.

Video Player Setup

All four players use the same PopAds.init() call — the only thing that changes is how you build the playerInstance.

Bitmovin

const player = new bitmovin.player.Player(container, { key: 'YOUR_BITMOVIN_KEY' })
await player.load({ hls: 'your-stream.m3u8' })

PopAds.init({
  playerInstance:  player,
  playerType:      'bitmovin',
  playerContainer: container,
  vmapUrl:         'https://app.popadsnetwork.com/api/vmap/pop-XXXXXXXX',
  creatorId:       creator.id,
})

Video.js

<video id="my-video" class="video-js" controls preload="auto">
  <source src="your-video.mp4" type="video/mp4">
</video>

<script>
  const player = videojs('my-video')
  PopAds.init({
    playerInstance:  player,
    playerType:      'videojs',
    playerContainer: player.el(),
    vmapUrl:         'https://app.popadsnetwork.com/api/vmap/pop-XXXXXXXX',
    creatorId:       creator.id,
  })
</script>

JW Player

const player = jwplayer('my-player').setup({ file: 'your-video.mp4' })
const container = document.getElementById('my-player')

PopAds.init({
  playerInstance:  player,
  playerType:      'jwplayer',
  playerContainer: container,
  vmapUrl:         'https://app.popadsnetwork.com/api/vmap/pop-XXXXXXXX',
  creatorId:       creator.id,
})

HTML5 native

<div id="player-wrap">
  <video id="my-video" controls>
    <source src="your-video.mp4" type="video/mp4">
  </video>
</div>

<script>
  const player = document.getElementById('my-video')
  PopAds.init({
    playerInstance:  player,
    playerType:      'html5',
    playerContainer: document.getElementById('player-wrap'),
    vmapUrl:         'https://app.popadsnetwork.com/api/vmap/pop-XXXXXXXX',
    creatorId:       creator.id,
  })
</script>

Display Banners

For display placements, no SDK is needed — drop a single <script> tag where you want the banner to render. PopAds injects an image into the slot, runs an IAB MRC viewability gate (≥50% of pixels visible for ≥1 continuous second) and fires the impression beacon only when the gate passes. See Impression Counting for the full rules.

Basic placement

<script
  src="https://app.popadsnetwork.com/api/tag/pop-XXXXXXXX?creator_id=42&content_id=article-123"
  async>
</script>

The placement's banner_size (set when you create it — 728×90, 300×250, etc.) determines which creatives are eligible.

Auto-refresh (optional)

Add data-refresh to rotate banners on a timer. Refresh is gated by viewability — it only fires when the slot is on-screen and the tab is visible.

<script
  src="https://app.popadsnetwork.com/api/tag/pop-XXXXXXXX"
  data-refresh="60"
  async>
</script>

Minimum refresh interval: 30 seconds.

Manual refresh (SPAs)

window.PopAds.refresh('popads-pop-XXXXXXXX')

Pause Ads

HBO Max / Hulu pattern: a centred ad renders when the viewer pauses the video, hides automatically when they resume. Distinct inventory from in-stream overlays — typically commands higher CPMs because viewer attention is undivided.

Creative dimensions

Size Notes
1280×720Standard (16:9). Renders crisp on every player size, primary recommendation.
1920×1080Premium (16:9, 4K-ready). Use for high-quality / cinematic inventory.

PNG, JPG, or WebP up to 500 KB. The SDK renders at 50% width × 50% height of the player, centred, with a close button.

Create the placement

In your dashboard → Placements → New Placement, pick Video Overlay Pause, choose creative size, and set the minimum pause duration (1–10 seconds — defaults 3, stops scrubbing from triggering ads). You'll get a tag URL like:

https://app.popadsnetwork.com/api/vast/pop-XXXXXXXX?t=pause

Wire it into the SDK

Pass the pause tag URL alongside (or instead of) your VMAP URL in the same PopAds.init() call:

PopAds.init({
  playerInstance:  player,
  playerType:      'bitmovin',
  playerContainer: document.getElementById('player-wrap'),
  vmapUrl:         'https://app.popadsnetwork.com/api/vmap/pop-VIDEOXXX',   // optional — pre/mid/post-roll + overlay
  pauseAdUrl:      'https://app.popadsnetwork.com/api/vast/pop-PAUSEXXX?t=pause', // optional — pause-screen ad
  pauseMinSeconds: 3,                  // optional — debounce
  contentId:       videoId,
  creatorId:       creatorUuid,
})

vmapUrl and pauseAdUrl are independent — pass either, both, or neither.

Linear-ad suppression

When a pre-roll / mid-roll / post-roll is playing, the SDK pauses the content player to overlay the ad. The pause-ad listener is automatically suppressed during that window so you don't get a pause ad layered on top of a linear ad.

Content & Compliance

PopAds classifies your content as short-form, long-form, or live and adjusts ad behaviour to satisfy the Coalition for Better Ads standards. Pass the classification at SDK init time; without it, the server infers from the content duration vs the placement's long-form threshold (default 8 min).

Pass it at init

PopAds.init({
  // …
  contentType:     'long_form',    // 'short_form' | 'long_form' | 'live'
  contentDuration: video.duration, // seconds — drives dynamic mid-rolls on long_form
})

What each classification does

contentType Mid-rolls? Skippable pre-roll? ≤30s creative required?
short_form
clips ≤ 8 min
No Yes (5s default) Yes — longer filtered out
long_form
movies, series
Yes — dynamic cadence No No
live
live streams
Pre-roll only (v1) No No

Mid-roll cadence (long-form)

Configured per placement in the dashboard. Dynamic cadence computes break points from the runtime contentDuration + four knobs:

  • Interval — how often to insert a break (e.g. 900s = 15 min)
  • Lead-in — protected window at the start (default 300s = 5 min)
  • Tail-out — protected window at the end (default 120s = 2 min)
  • Max breaks — hard ceiling, e.g. 6 breaks on a 2-hour film

A 90-min film with interval=900 → 5 breaks at 5/20/35/50/65 min. A 120-min film with the same config → 6 breaks (capped). Static-offset mode is still available for fixed-position breaks regardless of content length.

Audio & the unmute button

Linear ads start muted by default (web). The SDK draws an unmute button in-player; sound switches on only after the viewer clicks it. This satisfies two policies at once: browser autoplay (Chrome/Safari/Firefox block sound-on autoplay) and Better Ads ("auto-playing video with sound" is a flagged violation). Pass mutedOnStart: false only on surfaces where the user already opted into audio for the surrounding content (e.g. a music-streaming app).

Mixed catalogs

One placement can serve both short and long-form. Set allowed_content_types = [short_form, long_form] in the placement form and pass the right contentType per VMAP request. Per-content ad behaviour, single placement. Split into multiple placements if you want different floor CPMs or mid-roll cadences for shorts vs long-form.

Impression Counting

PopAds counts impressions using the IAB MRC standard. Every impression that lands in ad_impressions — and therefore every impression that's billed and paid out — has been viewability-gated. Inflated “loaded but never seen” counts don't exist on this network.

Display banners

The script tag served by /api/tag/{key} renders the creative immediately, then arms an IntersectionObserver at threshold 0.5 plus a 1-second continuous-visibility timer. When the gate passes, the script fires a single beacon to /api/impression via navigator.sendBeacon (with a 1×1 pixel fallback for legacy browsers).

  • ≥50% of the slot's pixels must be in the viewport.
  • For ≥1 continuous second.
  • Tab must be foregrounded (the visibilitychange event cancels the pending timer).
  • Scrolling out of view before 1s elapses cancels the timer; the gate re-arms on re-entry.
  • Each rendered creative fires exactly once. data-refresh rotation pulls a fresh creative + new beacon URL and re-arms the gate.

Video (VAST / VMAP)

The VAST 4.2 XML returned by /api/vast/{key} embeds a single <Impression> URL per <Ad> entry. Per the VAST spec, your player MUST trigger that URL when the ad's first frame is presented to the viewer — not on parse, not on preload. All compliant players (IMA SDK, Bitmovin, video.js, JW Player, html5-vast adapters) do this by default. PopAds doesn't count an impression for video until the player fires that beacon.

Pause ads

The pause-screen creative is fetched as a single VAST response when the viewer pauses. The SDK fires the embedded <Impression> URL the moment the pause overlay is rendered — same gating model as video.

Suspicious traffic

Beacons that fail PopAds' fraud signals (datacenter IP, headless browser, IP velocity, frequency-cap breach, etc.) are still recorded for forensics, but with payouts zeroed and an is_suspicious flag set. Advertiser dashboards and publisher revenue calcs exclude these rows automatically — you only see the IAB-viewable, paid count.

One endpoint, one count

Display, video, and pause all converge on /api/impression as the single billable event. The same fraud check, four-way revenue split (advertiser → publisher / affiliate / platform), and atomic balance deduction run on every impression regardless of format. There's no separate “display impression” pipeline.

Advertiser Measurement

Every impression captures content classification, ad position, fingerprint, IP-hash, datacenter flag, and the full fraud-stack outcome. Advertisers see this in their dashboard, which directly affects what they're willing to bid on your inventory. The more signal you send, the richer the advertiser's view — and the higher your effective CPMs.

Four measurement cards

Traffic Quality

Verified vs blocked impressions over 30 days, IVT rate, and reason breakdown (bot UA, datacenter IP, velocity cap, etc.). Proves to advertisers that PopAds is filtering invalid traffic before billing them.

Viewability

Display banners are 100% viewable by construction (IAB MRC IntersectionObserver gate); video viewability uses the dedicated viewable event (≥50% pixels × ≥2s continuous playback). Per-format split with progress bars.

Engagement

CTR overall + per-creative, video completion rate, skip rate, mute rate, average watch time per impression. Clicks attributed through /api/click/{imp} so they're joinable per-impression.

By Position

Per-campaign breakdown by ad position: pre-roll vs each mid-roll vs post-roll vs overlay / display / pause. The long-form differentiator — advertisers can see completion rate at mid-roll #1 vs mid-roll #6.

Content-type filter

Advertiser analytics ships with an All / Short / Long / Live pill alongside the 7d/30d/90d range. Lets buyers compare CPM efficiency across inventory types, decide which content classification to bid more aggressively on, and avoid blending unlike inventory in a single average.

Third-party verification (OMID)

When an advertiser attaches OMID verification scripts (Moat, IAS, DoubleVerify, etc.), they flow through transparently — no publisher action required. VAST responses include an <AdVerifications> block; display tags load the OMID Session Client and dispatch lifecycle events. The OMID layer is a no-op when no verifications are attached, so there's zero overhead for the common case.

What this means for your CPMs

Inventory with rich measurement signal (verified traffic, MRC-grade viewability, third-party verification, on-chain viewer identity) commands materially higher prices in the open market than blind-bid inventory. Sending contentType, contentDuration, creator_id, viewerWallet, and letting OMID flow through is the difference between premium and bulk pricing.

Audience Insights

Advertisers see an Audience Insights tab on their dashboard that breaks down who actually saw the ad — at whatever auth fidelity your platform supports. Two optional identity fields on PopAds.init unlock progressively richer views. Both are nullable per impression, can be passed together, and PopAds keeps them in separate columns so each tier renders independently.

Field Use it when Advertiser sees
viewerWallet Your users sign in with a Solana wallet Unique-wallet reach, wallet-age cohorts (fresh / aged / veteran), SOL + USDC balance distribution, top SPL tokens held across the audience, top-50 viewers table with Solana Explorer links
viewerExternalId Your users sign in via Facebook / Google / email / your own account system Unique-user reach + frequency distribution. Hash your internal user ID before sending — PopAds never sees raw PII
(neither) Anonymous views Falls back to fingerprint-based unique-device count (already captured on every impression)

Passing viewerWallet

Any Solana base58 string (32–44 chars). PopAds doesn't care where it came from — just that it's the logged-in user's wallet on your platform. A few common sources:

// 1. Phantom / Solflare / any wallet exposing window.solana
const viewerWallet = window.solana?.publicKey?.toBase58();

// 2. Solana Wallet Adapter (@solana/wallet-adapter-react)
const { publicKey } = useWallet();
const viewerWallet = publicKey?.toBase58();

// 3. Dynamic Labs (multichain auth UI)
const { primaryWallet } = useDynamicContext();
const viewerWallet = primaryWallet?.address; // already base58 for Solana wallets

// 4. Privy / Magic / Web3Auth / your own adapter
const viewerWallet = session.wallet?.address;

Then pass it like any other PopAds option:

PopAds.init({
  // …everything else…
  viewerWallet, // undefined for anonymous users — impression still counts, just no enrichment
})

Passing viewerExternalId

Any opaque string that uniquely and stably identifies the logged-in user on your platform. Never send raw user IDs, emails, or usernames — PopAds is a third party. Hash server-side with a salt you control:

// Server-side, where you already know the user. Returns a 64-char hex string.
import { createHash } from 'node:crypto';

const SALT = process.env.POPADS_VIEWER_SALT;   // long random string, set once, never rotate
                                               //   (rotating it makes returning users look new)
function viewerExternalId(internalUserId: string): string {
  return createHash('sha256').update(`${SALT}:${internalUserId}`).digest('hex');
}

Pass the hex digest into the SDK on the client:

PopAds.init({
  // …everything else…
  viewerExternalId, // 64-char sha256 hex from your backend
})

The salt matters because without it an adversary who knows your user ID format could rainbow-table the hash back to a known ID. With the salt, the digest is meaningless outside your platform — it's only useful as a stable opaque key for PopAds' frequency and reach maths.

What advertisers see — wallet path

  • Reach summary — unique wallets, total impressions, average frequency per wallet.
  • Wallet-age cohorts — fresh (<30d on-chain) / aged (30–180d) / veteran (180d+). Built from each wallet's earliest signature timestamp.
  • Balance cohorts — zero / small (<1 SOL) / mid (1–10 SOL) / large (10+ SOL). USDC balance shown separately on the top-viewer table.
  • Audience interest fingerprint — top SPL token mints held across the audience, weighted by impressions. Useful signal for which sub-communities your inventory reaches.
  • Top viewers — top-50 wallets by impressions, with Solana Explorer links so the advertiser can verify on-chain.

Each wallet is enriched on first sight via Solana RPC (balance, signature count, holdings, first-tx date) and cached for 24 hours. First dashboard load on a new campaign takes ~5 seconds while wallets enrich; subsequent loads hit the cache and are sub-second.

How to verify it's landing

After deploying, hit a placement as a logged-in user and check the most recent impression row:

SELECT viewer_wallet, viewer_external_id, fingerprint_hash, served_at
FROM ad_impressions
WHERE publisher_id = '<your-publisher-uuid>'
  AND served_at > now() - interval '5 minutes'
ORDER BY served_at DESC
LIMIT 5;

Wallet path: at least one row should have viewer_wallet populated. External-ID path: viewer_external_id should be the 64-char hex. Once 20–50 authenticated impressions land per advertiser, their Audience Insights tab unlocks the enriched view.

Why this is worth wiring up

No cookie-based DSP can show the advertiser who actually watched. Cookies are sandboxed and crumbling; mobile IDFA is gone; IP is noise. A Solana wallet is a stable, verifiable, public identity at the impression level. Inventory that publishes on-chain audience quality consistently commands a price premium over blind-bid inventory — that's the bid-side calculus driving CPMs on this network.

Contextual Targeting

If your videos are processed through ZiB Vision AI (you get a sidecar.json per content item), forwarding the relevant subset to PopAds unlocks contextual ad matching: advertisers can target placements by scene content — "play my Ferrari ad when a Ferrari appears on screen", "only run when the brand-safety score clears my floor", etc. The matcher reads the snapshot at ad-fire time and the resulting impression is tagged with the scene context for advertiser reporting and (later) learning-ranker training.

This section is optional — ads serve fine without it. Skipping just means your inventory falls back to the deterministic auction with no scene-level matching, and your inventory doesn't appear in the advertiser's Inventory browser.

When to fire

Once per content item, when ZiB tells you the sidecar is ready. ZiB fires a vision.complete webhook with { file_id, sidecar_file_id, ... } once the AI pass finishes. Use that as your trigger to fetch the sidecar from ZiB, trim it, and POST it to PopAds. Subsequent ad requests for the same content_id find the cache warm — no re-posting needed.

If ZiB re-processes a video (sidecar version bumps), re-posting is safe — PopAds upserts on (publisher_id, content_id) and COALESCEs the optional display fields, so you don't blank out a title by re-posting without one.

The endpoint

POST https://api.popadsnetwork.com/api/sidecar
Content-Type: application/json

{
  "placement_key":     "pop-6ea6afe9",                // your video_session placement
  "content_id":        "<your internal content UUID>",
  "title":             "Episode 42: The Investigation",
  "thumbnail_url":     "https://your-cdn.example.com/thumb.jpg",
  "duration_seconds":  1800,
  "sidecar": {
    "version":           "2.0",
    "video":             { "duration_s": 1800 },
    "content_format":    "interview",
    "keyframes": [
      { "ts": 12.5,  "setting": "studio_interior", "presenter_emotion": "calm",
        "objects": ["microphone", "desk", "laptop"], "brands_visible": [],
        "on_screen_text": [] },
      { "ts": 124.0, "setting": "studio_interior", "presenter_emotion": "engaged",
        "objects": ["microphone", "phone"], "brands_visible": ["Apple"],
        "on_screen_text": ["iPhone 17 Pro"] }
    ],
    "content_classification": {
      "iab_primary": "53",
      "iab_categories": [
        { "id": "53",   "name": "Technology & Computing", "tier": 1, "confidence": 0.92 },
        { "id": "5310", "name": "Consumer Electronics",      "tier": 2, "confidence": 0.81 }
      ]
    },
    "garm":            null,
    "detected_brands": ["Apple"],

    "ad_targeting": {                                 // v2.0 — all keys optional
      "contextual_keywords":     ["technology","interview","product launch"],
      "brand_affinity":          ["innovation-oriented","premium tech"],
      "competitor_exclusions":   ["samsung","google","huawei"],
      "ad_moment_candidates": [
        { "timestamp_s": 120, "type": "scene_break",      "score": 0.85 },
        { "timestamp_s": 480, "type": "topic_transition", "score": 0.72 }
      ],
      "cpm_floor_suggestion":    { "floor_usd": 8.50, "confidence": 0.78,
                                   "rationale": "premium production, brand-safe" },
      "suitable_for_ads":        true,
      "recommended_ad_categories": ["technology","consumer electronics","productivity"],
      "monetisation_notes":      "Strong demand expected from tech advertisers"
    },
    "intent": {
      "viewer_intent":     "entertainment",
      "content_intent":    "interview",
      "call_to_action":    "none",
      "emotional_tone":    "curious",
      "controversy_score": 0.05,
      "controversy_reason": null
    },
    "audience": {
      "description":        "Tech-curious adults interested in product launches",
      "psychographic_tags": ["innovation-oriented","early-adopter","professional"],
      "age_skew":           "25-44",
      "gender_skew":        "balanced",
      "affluence_signal":   "above-average",
      "knowledge_level":    "intermediate-to-advanced",
      "viewing_context":    "lean-back evening"
    }
  }
}

Response: 200 OK with { "ok": true }. Errors: 400 for malformed body, 404 if placement_key doesn't exist. The placement_key is your auth signal — same trust model as /api/vmap and /api/vast. HTTPS provides confidentiality in transit; no app-layer encryption needed.

Every field is optional. Older sidecars without ad_targeting / intent / audience still serve correctly — PopAds ingests defensively. Sending the new blocks unlocks scene-anchored mid-roll placement (ad_moment_candidates), competitor suppression (competitor_exclusions), publisher-suggested CPM floors (cpm_floor_suggestion), brand-safety kill switch (suitable_for_ads: false), and richer dashboard surfaces.

content_id must match your SDK init

The content_id you POST here must be byte-equal to the contentId you pass to PopAds.init({ contentId }) on the player side. PopAds uses it as the join key — when an ad request fires for content_id=X, the backend looks up the cached sidecar under the same key. Whatever stable identifier your CMS uses (your internal content UUID, not the ZiB file_id) — use the same string in both places.

Trimming — what to send, what to strip

The raw ZiB sidecar includes plenty of fields PopAds doesn't need (executive summaries, narrative arcs, target audience descriptions, monetisation notes). Strip those before sending. The fields PopAds actually uses:

Field Why PopAds needs it
versionDetect sidecar schema upgrades
video.duration_sInventory aggregates by hours
content_formatFormat mix in the inventory dashboard
keyframes[].tsPicks the keyframe nearest the player's mid-roll position. Renamed from ZiB's timestamp_s
keyframes[].settingDominant-setting summary
keyframes[].presenter_emotionEmotional-arc summary
keyframes[].objectsObject-presence matching against advertiser target_objects
keyframes[].brands_visibleBrand-presence matching against advertiser target_brands
keyframes[].on_screen_textOCR signal — useful for future text-based matching
content_classification.iab_primaryPrimary IAB v3 category id (string)
content_classification.iab_categories[]Full IAB classification {id, name, tier, confidence}[] for category targeting + rollups
garmBrand-safety filtering
detected_brandsContent-level brand rollup (deduped across keyframes)
ad_targeting.contextual_keywords[]Soft contextual matching against advertiser targeting
ad_targeting.competitor_exclusions[]Hard filter. PopAds suppresses any creative whose target_brands overlap (case-insensitive)
ad_targeting.ad_moment_candidates[]Hard scheduler input. VMAP uses these {timestamp_s, type, score} entries as mid-roll positions instead of the placement's static cadence
ad_targeting.cpm_floor_suggestionHard filter. PopAds drops campaigns whose cpm_bid_usdt is below max(placement.floor_cpm, sidecar.floor_usd)
ad_targeting.suitable_for_adsHard kill switch. false → empty VAST regardless of targeting
ad_targeting.recommended_ad_categories[]Advertiser-side discovery (appears in Inventory filters)
intent.viewer_intent / content_intent / emotional_toneAggregate intent mix + per-content labels in dashboards
audience.psychographic_tags[]"Top psychographics" aggregate + per-content chips
audience.{age_skew, gender_skew, affluence_signal, knowledge_level, viewing_context, description}Per-content audience qualifiers in the publisher Content Library expand view

The one rename to get right is keyframes[].timestamp_skeyframes[].ts. PopAds reads ts when picking the keyframe nearest the player's current position. The same rename does not apply to ad_targeting.ad_moment_candidates[].timestamp_s — that key stays timestamp_s because it's read by the VMAP scheduler, not the keyframe picker.

// Trim the raw ZiB sidecar to the ad-relevant subset before POSTing.
function trimSidecar(raw) {
  const sc  = raw ?? {};
  const ck  = sc.scene_understanding   ?? {};
  const cls = sc.content_classification ?? {};
  return {
    version:        sc.zib_sidecar_version,
    video:          { duration_s: sc.video?.duration_s },
    content_format: ck.content_format,
    keyframes: (ck.keyframes ?? []).map(kf => ({
      ts:                kf.timestamp_s,                     // ← rename
      setting:           kf.setting,
      presenter_emotion: kf.presenter_emotion,
      objects:           kf.objects         ?? [],
      brands_visible:    kf.brands_visible  ?? [],
      on_screen_text:    kf.on_screen_text  ?? [],
    })),
    content_classification: {
      iab_primary:    cls.iab_primary,
      iab_categories: cls.iab_categories ?? [],
    },
    garm:            cls.garm,
    detected_brands: cls.detected_brands ?? [],
    ad_targeting:    sc.ad_targeting,   // pass through as-is
    intent:          sc.intent,         // pass through as-is
    audience:        sc.audience,       // pass through as-is
  };
}

Mid-roll matching — player position

For PopAds to pick the right keyframe when a mid-roll fires, the SDK passes &t_pos=NNN (current player position in seconds) on every mid-roll / post-roll VAST request. Already supported — no action needed if you're using popads-sdk.js v1.0.6+. Older versions don't pass t_pos and PopAds falls back to the first keyframe (still better than no context).

How to verify it's landing

After sending a sidecar, fire a mid-roll on that content and check the impression:

SELECT
  content_id,
  content_timestamp_seconds,
  scene_context_at_serve->'brands_visible' AS brands_seen,
  scene_context_at_serve->'objects'        AS objects_seen,
  policy_version,
  served_at
FROM ad_impressions
WHERE publisher_id = '<your-publisher-uuid>'
  AND content_id   = '<content-id-you-just-posted>'
  AND served_at    > now() - interval '5 minutes'
ORDER BY served_at DESC
LIMIT 5;

You should see scene_context_at_serve populated with the keyframe's objects / brands_visible / setting, content_timestamp_seconds matching the mid-roll position, and policy_version = 'cpm_baseline' (this changes once the learning ranker ships).

What gets stored, what gets dropped

PopAds stores the trimmed JSON verbatim in content_sidecars.sidecar_json, plus the optional display fields (title, thumbnail_url, duration_seconds) on flat columns for fast aggregation. Nothing you send leaves PopAds — sidecar data is read by the contextual matcher (server-side) and aggregated into the advertiser-facing Inventory tab; it is never echoed to client code or third parties.

Why this is worth wiring up

Most contextual ad platforms have to derive scene understanding from text scraping or page metadata. ZiB's vision pipeline gives you keyframe-level object, brand, setting, and emotion data as structured JSON — that's signal the rest of the industry can't access. Forwarding it to PopAds means advertisers targeting "Ferrari" or "luxury watches" or "automotive" can find inventory by what's actually on screen, not by guessing from URLs. Premium contextual matches consistently command higher CPMs than blind run-of-network — the bid uplift flows back to your placements.

Creator Attribution

PopAds records three optional attribution fields on every impression. They're opaque to us — pass whatever IDs are most convenient on your side.

Field Max length Purpose
content_id 256 chars Per-piece-of-content revenue (article ID, video ID, etc.)
creator_id 256 chars Per-creator revenue (UUID, slug, integer — anything stable on your side)
creator_wallet 64 chars Optional — helps compute claimable balance per creator

Where to pass them

Either through the SDK config (preferred for video):

PopAds.init({ /* … */, contentId, creatorId, creatorWallet })

Or directly on the tag URL (for display banners or server-rendered VMAP):

https://app.popadsnetwork.com/api/vmap/pop-XXXX
  ?content_id=video-123
  &creator_id=creator-42
  &creator_wallet=GPxRxtGb...BCtR

PopAds doesn't validate or own the creator → wallet mapping

That's your responsibility — collect Solana addresses from creators during onboarding (or first claim), store them on your side, and pass them as recipient when calling the payout endpoint.

API Keys

API keys gate the read endpoints (/v1/stats, /v1/creators, etc.) and the seamless creator payout endpoint (/v1/payout). You don't need a key just to serve ads — only for backend automation.

Issue a key from the dashboard

  1. Sign in to app.popadsnetwork.com and open the publisher dashboard.
  2. Go to API Access.
  3. Set your daily and hourly USDC caps. These are circuit breakers in case the key leaks — pick numbers a few times higher than your typical throughput.
  4. Click Generate API key and approve the signature in your wallet.
  5. Copy the key from the modal — it's only shown once. Store it in your secrets manager (e.g. as POPADS_API_KEY).

Authenticate requests

curl -H "Authorization: Bearer $POPADS_API_KEY" \
  https://app.popadsnetwork.com/api/publisher/v1/stats

Rotate or revoke

Re-running Generate API key (now labelled Rotate key) replaces the existing key — the old one stops working immediately. Use this if a key has leaked or you need to change limits.

Self-imposed limits

Daily/hourly caps protect your own accrued ad revenue if a key leaks. They don't affect what PopAds can lose — every payout is checked against your accrued balance before dispatch, so a stolen key can't drain other publishers or the platform.

Read API

All read endpoints live under /api/publisher/v1/ and require a Bearer API key. Responses are scoped automatically to your publisher.

@popads/publisher (Node SDK)

A typed Node wrapper around all four endpoints. Recommended over raw fetch.

npm install @popads/publisher
import { PopAdsPublisher } from '@popads/publisher'
const popads = new PopAdsPublisher({ apiKey: process.env.POPADS_API_KEY })

const stats = await popads.getStats()
console.log(`${stats.balance.pendingUsdc} USDC available, ${stats.impressions.lifetime} lifetime impressions`)

GET /v1/stats

Account state — pending balance, lifetime + 30-day rollups, current cap usage.

{
  "publisher": {
    "id": "7f4f9f8d-…",
    "platformName": "rite3",
    "revenueSharePct": 70,
    "payoutsFrozen": false
  },
  "balance": {
    "pendingUsdc": 142.371500,
    "paidUsdc": 50.000000,
    "lifetimeRevenueUsdc": 192.371500
  },
  "impressions": { "lifetime": 18234, "last30d": 4521 },
  "revenue": { "last30dUsdc": 38.940000 },
  "payoutLimits": {
    "dailyUsdc": 5000, "hourlyUsdc": 1000,
    "dailyUsedUsdc": 124.0, "hourlyUsedUsdc": 12.0
  },
  "asOf": "2026-04-26T15:55:23.812Z"
}

GET /v1/creators

All creators ranked by lifetime revenue.

{
  "creators": [
    {
      "creatorId": "creator_42",
      "creatorWallet": "GPxRxtGb…BCtR",
      "impressions": 1248,
      "revenueUsdc": 12.480000,
      "lastSeenAt": "2026-04-26T15:42:11.000Z"
    }
  ],
  "count": 87
}

GET /v1/creators/{creatorId}

Per-creator detail with claimable balance — lifetime revenue minus what's already been dispatched to that creator's wallet.

{
  "creatorId": "creator_42",
  "creatorWallet": "GPxRxtGb…BCtR",
  "impressions": { "lifetime": 1248, "last30d": 320 },
  "revenue":     { "lifetimeUsdc": 12.480, "last30dUsdc": 3.200 },
  "payouts":     { "paidUsdc": 5.000, "claimableUsdc": 7.480 }
}

These are gross publisher numbers — not what creators should see

PopAds gives you publisher_earned per impression (typically 70% of gross ad spend after our platform fee). What you split between the creator, voters, your platform, treasury reserves etc. is your business model — apply that split before rendering anything in your creator-facing UI. Show creators only the number they can actually claim. Surfacing both the gross publisher share and the creator's slice on the same page invites “why is the bigger number not mine?” and leaks an implementation detail that doesn’t help them.

GET /v1/impressions

Paginated impression log. Pass nextCursor as ?before= for the next page.

GET /v1/impressions?limit=100&creator_id=creator_42

GET /v1/content

Per-content value estimates for every piece of content you've registered with PopAds via POST /api/sidecar. Each row carries the ZiB-suggested CPM, an estimated monthly publisher-side revenue forecast, and the classification signals advertisers see (IAB, detected brands, audience psychographics). Use this to render a "what's this video worth on PopAds" view on your creator dashboard.

curl -H "Authorization: Bearer $POPADS_API_KEY" \
  "https://app.popadsnetwork.com/api/publisher/v1/content?sort=cpm_desc&limit=50"
Param Type Default Notes
limitint50Max 200
offsetint0Pagination offset
sortstringcpm_descOne of cpm_desc (highest suggested CPM first), revenue_desc (highest 30-day projected revenue first), recent (newest sidecars first)

Response:

{
  "content": [
    {
      "content_id": "e21e5c38-7d04-42fd-9786-326a8f08d52f",
      "title": "Crypto Trader Daily — Episode 47",
      "thumbnail_url": "https://cdn.zibnetwork.com/objects/...",
      "duration_seconds": 1320,
      "indexed_at": "2026-05-26T00:18:19Z",
      "sidecar_version": "2.0",

      "estimated_cpm_usd":      12.50,
      "cpm_confidence":         0.78,
      "cpm_rationale":          "premium production, brand-safe audience",
      "suitable_for_ads":       true,

      "impressions_30d":        47000,
      "ad_breaks_count":        4,

      "estimated_monthly_publisher_revenue_usd": 411.25,
      "publisher_revenue_share_pct":             70,

      "iab_primary": "596",
      "iab_categories": [
        { "id": "596", "name": "Cryptocurrency",     "tier": 3, "confidence": 0.92 },
        { "id": "53",  "name": "Business & Finance", "tier": 1, "confidence": 0.85 }
      ],
      "top_detected_brands": ["Ledger", "Phantom", "Trezor"],
      "psychographic_tags":  ["crypto-native", "early-adopter", "investor"],
      "viewer_intent":       "education",
      "content_format":      "interview"
    }
  ],
  "total":  142,
  "limit":  50,
  "offset": 0,
  "sort":   "cpm_desc"
}

Field notes:

  • estimated_cpm_usd is ZiB Vision AI's suggested CPM floor for the content. Combines audience inference, brand-safety, production quality. Forward-looking estimate, not a guaranteed clearing price.
  • estimated_monthly_publisher_revenue_usd = cpm × (impressions_30d / 1000) × (publisher_revenue_share_pct / 100). This is publisher-side revenue, before any creator share you apply on your end. Returns null when either input is missing.
  • impressions_30d is the actual billable impression count over the trailing 30 days. Excludes pre-dispatched + suspicious rows.
  • suitable_for_ads: false means PopAds is suppressing ads on this content entirely (brand-safety kill switch from the sidecar). Revenue estimate will still be present but won't realise.

GET /v1/content/{content_id}

Same shape as a single entry from the list endpoint above, scoped to one content. Useful when your dashboard navigates to a specific creator video page and wants the live value estimate.

curl -H "Authorization: Bearer $POPADS_API_KEY" \
  "https://app.popadsnetwork.com/api/publisher/v1/content/e21e5c38-7d04-42fd-9786-326a8f08d52f"

Returns 404 when the content_id has no sidecar registered yet (you haven't POSTed it via /api/sidecar) or belongs to another publisher. Returns the same JSON object shape as a single entry in the list endpoint's content[] array — no envelope wrapping.

Creator Payouts

When a creator clicks "Withdraw" on your platform, your backend calls one endpoint and we dispatch USDC from the PopAds treasury straight to their Solana wallet. No creator-side wallet interaction, no SOL needed, one HTTP call.

POST /v1/payout

import { PopAdsPublisher, PopAdsInsufficientBalance, PopAdsRateLimited } from '@popads/publisher'

try {
  const result = await popads.payout({
    recipient:      creator.solanaWallet,
    amountUsdc:     1.50,
    idempotencyKey: PopAdsPublisher.idempotencyKey({ claimId }),
  })
  // result.txSignature is the on-chain signature you can link on Solscan
} catch (err) {
  if (err instanceof PopAdsInsufficientBalance) { /* not enough accrued revenue */ }
  else if (err instanceof PopAdsRateLimited)    { /* hit your hourly/daily cap */ }
  else throw err
}

Idempotency

The idempotencyKey is unique per publisher. Replays of the same key never double-pay:

Original status Replay response
dispatched 200 with original txSignature and replay: true
pending 409 — still in flight, retry shortly
failed 422 with original error — pick a new key

Use a deterministic key per claim attempt (e.g. ${claimId}-${attempt}). Safe to retry on transient errors with the same key.

Error codes

Status Reason
400Invalid recipient address or amount
401Invalid / missing API key
402Insufficient publisher balance
403Payouts frozen by PopAds admin
409Idempotency key still in flight
422Idempotency key previously failed — pick a new one
429Hourly or daily payout cap exceeded
502Chain dispatch failed — safe to retry with same key

Webhooks

PopAds POSTs payout events to a URL you register in the dashboard. Bodies are JSON, signed with HMAC-SHA256 over the raw body using a shared secret.

Setup

Dashboard → API Access → Webhooks → enter your https:// endpoint → Save. We return a signing secret once; copy it into your env (e.g. POPADS_WEBHOOK_SECRET).

Events

Event When
payout.dispatchedAfter successful chain dispatch
payout.failedAfter dispatch fails on chain

Verifying signatures

Always verify against the raw request body — re-stringifying JSON breaks the HMAC.

import express from 'express'
import { verifyWebhookSignature } from '@popads/publisher'

app.post('/popads-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8')
  const ok = verifyWebhookSignature(
    rawBody,
    req.header('x-popads-signature'),
    process.env.POPADS_WEBHOOK_SECRET,
  )
  if (!ok) return res.status(401).end()

  const event = JSON.parse(rawBody)
  switch (event.event) {
    case 'payout.dispatched': /* mark claim paid */ break
    case 'payout.failed':     /* show retry option */ break
  }
  res.status(200).end()
})

Headers

Header Value
X-PopAds-Signaturesha256=<hex hmac of body>
X-PopAds-EventEvent name (same as body event field)
X-PopAds-DeliveryUnique delivery ID — log it for debugging

Delivery semantics

Single attempt with a 5-second timeout, fired immediately after dispatch. Failures are logged but the payout itself is unaffected — your endpoint being down won't reverse a chain transfer. Until automatic retries land, treat webhooks as best-effort and reconcile by polling /v1/payout with the same idempotency key if you need certainty.

Network

PopAds runs on Solana. Today the platform is on devnet for integration testing. The same host (app.popadsnetwork.com) and the same endpoints flip to mainnet at GA — no code change on your side, only the underlying chain.

USDC mints

Network Circle USDC mint
Devnet4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU
MainnetEPjFWdd5AufqSSqeMBh7RV2HFxJd5Fyv1FkrTFNxp7DN

Test funds (devnet)

All payouts

Dispatched as standard SPL transferChecked instructions. PopAds creates the recipient's USDC associated token account idempotently and pays the rent — creators never need SOL to receive funds.

Need Help?

Stuck on integration? Reach out via any of the channels below — include your publisher ID and any failing idempotencyKey if applicable.

Full API reference

The version-controlled API reference lives at the dashboard host and is always in sync with what's live:

app.popadsnetwork.com/docs/publisher-api.md

Response times

Telegram: usually within minutes • Email: within 24 hours