Everything you need to integrate PopAds — from signup to creator payouts on Solana.
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.
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.
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.
PopAds reviews new publisher applications manually. You'll start in pending status until an admin marks you active.
In the dashboard, go to Placements → New Placement. Each placement gets a permanent key like pop-7f3bafe6 and a tag URL. See Creating Placements.
For video, drop the SDK script tag and call PopAds.init(). For display banners, drop a single script tag. See SDK Installation.
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.
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.
Never lowercase a Solana wallet address. Treat them as opaque strings.
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) |
| If you want… | Create this |
|---|---|
| Pre-roll + overlay during the same video | One 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 videos | One 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 banner | Three placements: one video_session (covers pre-roll and overlay), one pause_screen, one display. Wire all three into the relevant pages. |
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).
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
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).
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 Video | video_session | pop-VIDEO123 |
| Watch Page Pause | pause_screen | pop-PAUSE456 |
| Watch Page Top Banner | display (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
vmapUrl (the video_session placement). Single VMAP, all four break types.pauseAdUrl (the pause_screen placement). Suppressed automatically while a linear ad is playing.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.
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.
Drop this once per page, before your player code runs:
<script src="https://app.popadsnetwork.com/popads-sdk.js" async></script>
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',
})
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.
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.
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.
All four players use the same PopAds.init() call — the only thing that changes is how you build the playerInstance.
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 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>
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,
})
<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>
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.
| Size | Notes |
|---|---|
| 1280×720 | Standard (16:9). Renders crisp on every player size, primary recommendation. |
| 1920×1080 | Premium (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.
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
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.
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.
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).
PopAds.init({
// …
contentType: 'long_form', // 'short_form' | 'long_form' | 'live'
contentDuration: video.duration, // seconds — drives dynamic mid-rolls on long_form
})
| 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 |
Configured per placement in the dashboard. Dynamic cadence computes break points from the runtime contentDuration + four knobs:
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.
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).
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.
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.
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).
visibilitychange event cancels the pending timer).data-refresh rotation pulls a fresh creative + new beacon URL and re-arms the gate.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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) |
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
})
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.
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.
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.
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.
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.
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.
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.
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.
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 |
|---|---|
| version | Detect sidecar schema upgrades |
| video.duration_s | Inventory aggregates by hours |
| content_format | Format mix in the inventory dashboard |
| keyframes[].ts | Picks the keyframe nearest the player's mid-roll position. Renamed from ZiB's timestamp_s |
| keyframes[].setting | Dominant-setting summary |
| keyframes[].presenter_emotion | Emotional-arc summary |
| keyframes[].objects | Object-presence matching against advertiser target_objects |
| keyframes[].brands_visible | Brand-presence matching against advertiser target_brands |
| keyframes[].on_screen_text | OCR signal — useful for future text-based matching |
| content_classification.iab_primary | Primary IAB v3 category id (string) |
| content_classification.iab_categories[] | Full IAB classification {id, name, tier, confidence}[] for category targeting + rollups |
| garm | Brand-safety filtering |
| detected_brands | Content-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_suggestion | Hard filter. PopAds drops campaigns whose cpm_bid_usdt is below max(placement.floor_cpm, sidecar.floor_usd) |
| ad_targeting.suitable_for_ads | Hard 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_tone | Aggregate 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_s → keyframes[].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
};
}
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).
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).
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.
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.
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 |
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
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 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.
POPADS_API_KEY).curl -H "Authorization: Bearer $POPADS_API_KEY" \
https://app.popadsnetwork.com/api/publisher/v1/stats
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.
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.
All read endpoints live under /api/publisher/v1/ and require a Bearer API key. Responses are scoped automatically to your publisher.
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`)
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"
}
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
}
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 }
}
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.
Paginated impression log. Pass nextCursor as ?before= for the next page.
GET /v1/impressions?limit=100&creator_id=creator_42
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 |
|---|---|---|---|
| limit | int | 50 | Max 200 |
| offset | int | 0 | Pagination offset |
| sort | string | cpm_desc | One 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.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.
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.
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
}
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.
| Status | Reason |
|---|---|
| 400 | Invalid recipient address or amount |
| 401 | Invalid / missing API key |
| 402 | Insufficient publisher balance |
| 403 | Payouts frozen by PopAds admin |
| 409 | Idempotency key still in flight |
| 422 | Idempotency key previously failed — pick a new one |
| 429 | Hourly or daily payout cap exceeded |
| 502 | Chain dispatch failed — safe to retry with same key |
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.
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).
| Event | When |
|---|---|
payout.dispatched | After successful chain dispatch |
payout.failed | After dispatch fails on chain |
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()
})
| Header | Value |
|---|---|
X-PopAds-Signature | sha256=<hex hmac of body> |
X-PopAds-Event | Event name (same as body event field) |
X-PopAds-Delivery | Unique delivery ID — log it for debugging |
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.
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.
| Network | Circle USDC mint |
|---|---|
| Devnet | 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU |
| Mainnet | EPjFWdd5AufqSSqeMBh7RV2HFxJd5Fyv1FkrTFNxp7DN |
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.
Stuck on integration? Reach out via any of the channels below — include your publisher ID and any failing idempotencyKey if applicable.
Join our Telegram channel for fast integration support.
Join ChannelDetailed technical questions, incident reports, billing.
engineering@popadsnetwork.comThe version-controlled API reference lives at the dashboard host and is always in sync with what's live:
app.popadsnetwork.com/docs/publisher-api.mdTelegram: usually within minutes • Email: within 24 hours