Sponsors & the sponsor overlay
The Sponsors module lets a workspace sell on-stream placements to advertisers without touching the video feed. Creatives render through a transparent web page that each creator adds to OBS as a Browser Source; the page polls the platform and shows the right creative at the right time with an always-on disclosure label. Impressions are counted server-side against the live poller's viewer counts.
Nothing is relayed or spliced into the stream. Platform ad breaks (Twitch/YouTube) are untouched and unrelated.
Concepts
| Thing | What it is |
|---|---|
| Sponsor | The advertiser (name, site, logo, contact). Workspace-scoped; plan limit limits.sponsors. |
| Campaign | A flight for one sponsor: placement, cadence (display_seconds every interval_seconds), optional start/end, disclosure text, rate (none / cpm / flat), optional CPM budget cap, and creator targeting (all profiles or a chosen set). Statuses: draft → active ⇄ paused → ended. |
| Creative | An image, video (mp4/webm, plays muted and looped for the display window) or text unit (headline, body, CTA) with a rotation weight and an approval status. A campaign cannot be activated without an active, approved creative. |
| Advertiser | A person at the sponsor with a login to the self-serve portal (sponsor_users). Sees only that sponsor's campaigns. |
| Impression | One showing of one creative on one creator's overlay in one time slot: shown_at, seconds, viewers (profile's live_viewers at that moment), is_live (was the live poller reporting the creator live). Unique per (creative, profile, slot) so repeated polls never double count. |
Viewer-impressions = Σ viewers over impressions; this is the billable unit for CPM. Estimated value = viewer-impressions ÷ 1,000 × CPM rate, or the flat fee.
Video creatives: 16:9 and 9:16
A video creative holds up to two renditions — video_url (16:9, landscape) and video_url_portrait (9:16) — plus a poster frame, a fit rule and probed metadata (video_meta). Uploads (Uploads::video → VideoService) are sniffed and probed with ffprobe: MP4/WebM only, H.264 / VP8 / VP9 / AV1, ≤ 60 s, ≤ 40 MB, at least 720p on the short side, and the aspect ratio must be within tolerance of 16:9 or 9:16 (square or odd sizes are rejected with the exact dimensions in the message). The upload form drops the clip into the matching field automatically and extracts a poster.
Canvas per creator. Each creator profile has an overlay_orientation: landscape (OBS browser source 1920×1080, the default) or portrait (1080×1920 for TikTok Live, Shorts, mobile). Switch it on /dashboard/sponsors/overlay; ?orientation= on the overlay URL overrides it for testing. Campaigns carry a placement for each canvas: placement (banner / corner / lower third) and placement_portrait (p_card bottom card, p_top top strip, p_full full-height 9:16 takeover).
Which file plays (SponsorService::creativePayload): the rendition matching the canvas. If only the other one exists, it is used with fit forced to contain — letterboxed over a blurred, scaled copy of itself — so nothing is ever cropped to a strip. With a matching rendition the creative's own fit applies (cover crops edges to fill the slot; contain shows the whole frame). The website banner always uses the landscape rendition.
Generated renditions. Ticking Generate the missing format automatically queues sponsors.video_rendition (queue media, deduped per creative): ffmpeg scales the clip to fit 1280×720 or 720×1280 over a blurred padded background, H.264, 30 fps, muted, stored next to the original; rendition_status shows queued / done / failed on the creative card and generated files are marked in video_meta. It costs CPU on the VPS (a 30 s clip takes roughly its own length to encode), so it is per-creative on request, not automatic.
Advertiser brief: 16:9 at 1280×720 and 9:16 at 720×1280, 10–30 s, H.264 MP4, message inside the middle of the frame, no reliance on sound. One format is enough when auto-generation is on.
Selective scheduling
Besides the cadence (display_seconds every interval_seconds) and flight dates, each campaign has scheduling rules (sponsor_campaigns.schedule, JSON) that decide when a showing may happen:
| Rule | Effect |
|---|---|
live_only | Only while the live poller reports the creator live (otherwise the overlay may show it whenever OBS is open). |
min_viewers | Only when the creator's current live viewers are at or above this number. |
max_per_hour | At most N showings per hour per creator (0 = only the cadence limits it). |
days, hours | UTC day-of-week list and hour windows, e.g. Fri–Sat 18:00–23:00. |
daily_budget_cents | CPM campaigns stop for the rest of the UTC day once today's estimated spend reaches the cap (budget_cents is the lifetime cap). |
priority (1–10) | Share of voice when two campaigns want the same slot: a priority-8 campaign shows 8 times for every 2 of a priority-2 one, still deterministic per slot. |
Rules are evaluated in SponsorService::eligibleCampaigns() every poll; scheduleBlock() gives the reason a campaign is held back. Pending or rejected campaigns and creatives are never eligible.
Advertiser portal
Advertisers get their own login at /advertise — no workspace role is granted. Invite them from the sponsor's Edit dialog on /dashboard/sponsors (email → invites row with role = advertiser + sponsor_id; the link at /invite/{token} links the account to the sponsor). An account with sponsor access and no workspace membership lands on the portal after login.
What they see and do:
- Overview — impressions, viewer-impressions, verified-live impressions, estimated spend vs budget, daily chart, campaign table (7/30/90-day ranges).
- Campaign — the same stats and breakdowns the workspace sees (daily, by creative, by creator), the schedule in plain words, targeted creators, creatives with approval status, CSV export of raw impressions (audited).
- Propose a campaign — placement, cadence, flight, budgets and scheduling rules. Created as
status = draft,approval_status = pending,rate_type = none; owners are notified. The workspace sets the rate and creator targeting, approves (or rejects with a note), then activates. - Submit creatives — image/video/text; each is
approval_status = pendinguntil an admin approves it on the campaign page. Rejections carry a note back to the advertiser (in-app notification).
Every read is tenant-scoped and sponsor-scoped (AdvertiserController::ownCampaign): other sponsors' campaigns are 404, other tenants' are unreachable. tests/AdvertiserTest.php proves both.
Money is still manual: the portal shows the estimated spend to invoice.
Agencies (advertiser portal)
An agency is a team that manages several sponsor brands ("clients"), possibly across workspaces. Tables: agencies, agency_members (owner | manager | analyst; a person belongs to one agency), agency_clients (agency ↔ tenant-scoped sponsor). Service: App\Modules\Sponsors\Services\AgencyService; portal pages under /advertise/agency/* (AgencyController).
- Getting brands in — the workspace still invites an email as the brand's advertiser (the only door into a brand). A manager/owner then attaches that brand on
/advertise/agency/clients; from then on every team member can open it in the client switcher. Detaching (owner) removes the team's access, not the attacher's own direct access. - Seats —
AdvertiserAccessresolves the strongest access per sponsor: directadvertiseror agencyowner/manager= write; directvieweror agencyanalyst= read-only. Read-only seats can open everything but every write action inAdvertiserController(propose, creatives, uploads, conversions, top-ups, duplicate) returns 403; the sidebar says read-only and the propose button is hidden. - Team —
/advertise/agency/team: invite by email with a role (invitesrow withrole = agency,agency_id,agency_role; accepted at/invite/{token}), change roles, remove (an agency always keeps one owner), agency details and billing email. - Overview —
/advertise/agency: totals and per-client rows (impressions, verified live, viewer impressions, estimated CPM spend, budget, balance, clicks, conversions, active/pending/rejected) for 7/30/90 days or afrom/towindow, impressions-per-day across clients, and an approvals list of every pending or declined campaign/creative across clients with the workspace's note. - Consolidated billing —
/advertise/agency/billing: balances per client (funds stay per brand — never pooled), invoices from every workspace with pay links, combined ledger, statement CSV (/advertise/agency/statement.csv?from&to) and performance CSV (/advertise/agency/performance.csv?days|from&to, one row per client × campaign). Exports are audited. - Duplicate a campaign — from any campaign page a write seat can copy settings and creatives as a new pending proposal for the same or another reachable brand (
POST /advertise/campaigns/{id}/duplicate,AgencyService::duplicateCampaign). Rate resets to none, approval to pending, and creator targeting is only carried over within the same workspace. - Agency report — members get the weekly/monthly email report kind
agency(consolidated totals, per-client rows, waiting approvals); see reports.
Isolation: every client read goes through that client's TenantScope; tests/AgencyTest.php proves an agency cannot see a brand it has not attached, that analysts cannot write, and that detaching revokes access.
Website ad zones (Squren and other networks)
Ad networks such as Squren count views in the visitor's browser, so their tags belong on the website only — an OBS overlay is one browser regardless of the audience, and serving network ads through it would earn nothing and breach their terms. On-stream placements stay direct-sold through campaigns.
Zones (ad_zones, platform-wide): profile_sidebar, community_sidebar, live_directory. Filling order per page (AdZoneService::fill):
- A direct sponsor campaign eligible for the page's creator (same rotation as the overlay).
- Otherwise, if feature flag
ads.networkis on and the zone is enabled, the network tag renders inside a sandboxed<iframe srcdoc>sized to the zone (AdProvider→TagProvider; providerssqurenandtagboth take a pasted HTML tag). Removeallow-popupsfrom the zone's sandbox flags to block popunders. - Otherwise nothing.
Measurement. Every fill is one ad_zone_events impression (source sponsor or network, the zone, campaign/creative/profile for sponsor fills, tenant_id 0 for network fills), written server-side at render with the tracked links' bot filter and de-duplicated per visitor (IP + UA + day hash), zone and creative for 10 minutes. A sponsor banner's call to action goes through the campaign's tracked link as /go/{code}?s=web&z={zone} when the campaign has a landing URL, or when the creative has no CTA URL of its own and the sponsor has a website (a creative-specific CTA URL on a campaign without a landing page is kept as written and not counted), so clicks land in sponsor_link_hits with source = web and the zone. These are website numbers: shown on the campaign tracking panel ("Website … banner impressions · clicks · CTR", per zone, and a Web column per creator), never added to viewer-impressions and never billed. Network clicks happen inside the network's iframe and cannot be measured here; /admin/ads shows 30-day sponsor impressions, sponsor clicks and network renders per zone (AdZoneService::zoneTotals) to reconcile against the network's own report.
Platform admins manage zones at /admin/ads (provider, size, tag, sandbox, enabled) and the flag at /admin/flags. Squren publishes no reporting API; enter its revenue manually from their dashboard. Test a tag on the live directory before enabling it on creator pages.
Pre-roll and post-roll video ads (rolls)
Roll ads play in krunc.com's own player surfaces on a creator page: the live player (when the creator is live and the Live section is on) and the clip / VOD lightbox. They never touch the OBS overlay or the creator's stream on Twitch/Kick/YouTube — those players belong to the platforms.
Campaign. A roll campaign is an ordinary sponsor campaign whose placement is preroll (plays before the live player mounts — the platform embed or the Krunc player — and before a clip or VOD starts) or postroll (plays when a YouTube clip/VOD reaches its end, and over the live player when the stream ends, just before the page reloads into its offline state). Everything else is shared: approval, creator consent per campaign, flight dates, schedule rules, priority, budgets, wallet or invoice funding, tracked links. interval_seconds / display_seconds are ignored for rolls. SponsorService::placements() lists overlay + roll placements; pick() (overlay) and bannerFor() skip roll campaigns; RollService::decide() only sees roll campaigns.
Creatives. Two kinds, validated against the campaign (SponsorController::creativeData):
video— an uploaded MP4 or WebM, at most 25 MiB (RollService::MAX_BYTES) and 60 s, probed with ffprobe like overlay clips (Uploads::videowith the roll cap; the upload endpoints takeroll=1). 16:9 is recommended; a 9:16 clip plays letterboxed. The probed length becomesduration_ms, which decides the skip rule, so pasted URLs are refused on roll campaigns. Clicks go through the campaign's tracked link (/go/{code}?s=web&z=preroll|postroll, so they appear with the website clicks), or the creative's own CTA URL when the campaign has no landing page.vast— a VAST 2–4 tag URL (https only). Only brands whosesponsors.vast_accessisgrantedmay save one. The tag is fetched and parsed when the creative is saved (a dead tag is not saved) and again at every play (Vast::resolve: wrappers followed up to 3 levels, DOCTYPE/entities refused, network access off while parsing). The player uses one progressive MP4/WebMMediaFile(closest to 720p), the tag'sDuration,ClickThrough, and fires itsImpression, quartile,complete,skip,ClickTrackingandErrorURLs from the viewer's browser as VAST expects. HLS/DASH-only tags and tags over 60 s are rejected; the tag'sskipoffsetis ignored in favour of our rule.vast_checked_at/vast_erroron the creative show the last fetch; a failing tag is skipped at play time (the next candidate plays) and the error is shown on the card.
Skip rule. An ad of 30 s or longer can be skipped after 10 s; a shorter ad cannot be skipped (RollService::skipAfter). The server sends skip_after (seconds or null) with every decision; the player renders "Skip ad in N" → "Skip ad ▸" or "Your video starts after the ad".
Storage. Ad videos (rolls and overlay clips, including generated renditions) are written through the storage.video binding: Bunny CDN Edge Storage (App\Providers\Storage\BunnyStorage) when BUNNY_STORAGE_ZONE, BUNNY_STORAGE_KEY and BUNNY_CDN_HOST are set, else the default storage driver. Files are PUT to https://{region.}storage.bunnycdn.com/{zone}/{key} with the zone's own access key and served from the pull zone host. Posters stay on the default storage. /admin/ads shows which store is live.
VAST access. Advertisers click Request VAST access on the portal overview (POST /advertise/vast/request → vast_access = requested, platform admins are notified). A platform admin grants or revokes on /admin/ads#vast (POST /admin/ads/vast/{sponsorId}, decision = granted|revoked, optional note; advertisers are notified). Revoking takes effect at the next decision — VAST creatives are filtered out while access is anything but granted. Workspace admins see the status as a badge on the sponsor card but cannot change it: a VAST tag runs a third party's ad server on creator pages, so the platform decides.
Serving and measurement. The creator page carries data-rolls + data-handle + data-roll-pre/post and loads assets/js/rolls.js only when a roll campaign is eligible for that creator (RollService::kindsIn(eligibleCampaigns)). Per play the script asks GET /rolls/{handle}.json?kind=preroll|postroll&ctx=live|clip|vod; RollService::decide() applies the usual eligibility, a 10-minute per-visitor cap per creator and kind (FREQ_SECONDS, same visitor hash + bot filter as the ad zones), priority-weighted campaign choice, weighted creative choice, resolves VAST, and returns the payload with a signed token (HMAC of tenant, campaign, creative, profile, kind, context, duration, time; 30 min TTL). The player posts {token, event} to POST /rolls/event: start inserts one sponsor_impressions row with viewers = 1, surface = preroll|postroll, context, visitor_hash, visible = 1 (deduped per visitor / creator / campaign / kind for 10 min), so CPM spend (AdBillingService::accrueSpend), budgets, creator earnings and invoices count roll views as viewer-impressions with no new plumbing; complete, skip, click, error go to sponsor_roll_events (one per impression and type, and only after a start). Forged or expired tokens get 422. Campaign pages (workspace + advertiser) show the funnel (starts, completed %, skipped %, clicks, playback errors, by surface and by context); the advertiser overview shows brand totals; /admin/ads shows platform totals. Kill switch: feature flag ads.rolls (on when missing).
Horizontal and vertical (orientation). A roll creative holds media for one or both shapes: a 16:9 clip in video_url and/or a 9:16 clip in video_url_portrait (the upload probe decides which field a clip lands in; each rendition carries its own probed length in video_meta), or a 16:9 VAST tag in vast_url and/or a 9:16 tag in vast_url_portrait. On save each tag is resolved for its own shape (Vast::resolve($url, orientation): MediaFiles whose declared size matches the shape come first, then the one closest to 720p / 720×1280) and a tag whose chosen MediaFile is the other shape is refused with the size it returned (files without width/height are taken on trust). The player sends the shape of its box (o=landscape|portrait, R.orientationOf(): portrait when the box is taller than wide, e.g. the Krunc player under a phone held upright); RollService::decide() orders each campaign's creatives matching shape first (weighted), then the rest letterboxed, so a campaign with only horizontal media still plays in a vertical box (with bars) rather than losing the slot. The payload carries orientation (the box) and media.orientation / media.fit (cover when they match, contain otherwise; the .roll video always uses object-fit: contain, so nothing is cropped); the token, the impression and the events record the box's orientation and the funnel shows the horizontal / vertical split.
Player behaviour (rolls.js): the ad overlays the player box (.roll), tries to play with sound (the lightbox opens from a click, so it usually can) and falls back to muted with a Tap for sound button; disclosure label + brand (+ optional label) top-left, click-through button top-right, countdown bottom-left, skip/no-skip bottom-right; clicking the video opens the click-through in a new tab. An ad that has not started within 8 s, or errors, is skipped (error beacon) so content is never held hostage. The lightbox pre-roll runs before the provider iframe is mounted; a late answer after the viewer closed the lightbox is dropped. Post-roll limits: YouTube embeds report the end of the video through the IFrame API's postMessage channel (enablejsapi=1), so clip/VOD post-rolls work for YouTube only — Twitch and Kick embeds expose no end event. Live post-rolls work for every platform because the page's own 15 s poll sees the stream end; in the Krunc player the stream's end is seen at once (the playlist goes away) and the post-roll runs before the reload.
Audience profile per creator
What an advertiser buys against, per creator (AudienceService, Profiles module):
- Measured viewership — the live poller writes one
live_samplesrow per live account per poll (every 2 min). The hourlyaudience.rolluptask folds samples intocreator_audience_dailyper platform plus anallrow = combined concurrent viewers across platforms (sum per sample minute), with minutes live, average, peak and the top category; samples older than 3 days are pruned. - Platform reach — follower counts from connected
social_accounts(refreshed by the social token/profile sync). - Declared context — on the profile editor (
/dashboard/profile): main region, age band, languages, content category (or auto from streams), free-text notes. Stored increator_profiles.audience; validated againstAudienceService::REGIONS/AGE_BANDS. - Summary —
compute()builds a 30-day card (followers per platform, avg/peak concurrent per platform and combined, hours live, stream days, category, sponsor showings) and caches it increator_profiles.audience_stats(refreshed by the roll-up, or on demand after a day).
Shown as audience cards on /dashboard/sponsors (Creators & reach) and on the advertiser portal at /advertise/creators — only creators with sponsorships enabled, never another workspace's. When proposing a campaign, advertisers tick the creators they want (requested_profile_ids); approving the proposal invites them, and consent still rests with each creator.
Creator consent per campaign
The branded-content disclosure obligation on Twitch, YouTube and Kick is the creator's, so the yes/no is theirs. A campaign reaches a creator's stream only when a sponsor_campaign_creators row for that campaign and profile is accepted:
- The workspace invites creators from the campaign form ("invite every creator" or a checkbox list) or the Invite more creators box — rows are created as
invited, never accepted on the workspace's behalf. Invited creators are notified. - The creator (the user set as
creator_profiles.user_id) sees the offer under Sponsorship offers (/dashboard/sponsors/offers): what shows, cadence, schedule rules, flight, approved creatives, and what they earn (the campaign's defaultcreator_rate_type/creator_rate_cents, or a per-creator override set by the workspace on the campaign page). They accept, decline (with a note) or later withdraw; the workspace can remove a creator, which the creator cannot undo. SponsorService::eligibleCampaignsserves only accepted rows; the campaign'sprofile_idscolumn is legacy and ignored. Accepting provisions the creator's tracked link and promo code so the overlay carries them immediately.
tests/CreatorOffersTrackingTest.php covers the flow; existing tests use TestCase::acceptAll() as the consent fixture.
Tracked links, QR codes, promo codes, conversions
Platforms do not expose clicks on stream, so attribution lives inside the stream and on the advertiser's side:
- Short links —
/go/{code}(7-character code, no 0/O/1/I) per campaign and per (campaign, creator), pointing at the campaign'slanding_url(falls back to the sponsor website) withutm_source,utm_medium=stream|qr|web,utm_campaign,utm_content=<handle>appended. Hits are recorded insponsor_link_hitswith theirsource(streamtyped from the overlay,qrscanned,webclicked on a website banner, plus thezone), de-duplicated per visitor (IP + UA + day) for 10 minutes across sources, and crawler user agents are ignored.?s=qrmarks a scan,?s=web&z=<zone>a website click; the link row keepsclicks/scans/web_clickscounters. - QR codes —
/go/{code}/qr.svgrenders the scan URL with the built-in encoder (App\Core\Qr: byte mode, EC level M, versions 1–10, verified module-for-module against a reference encoder). With Show QR code + short link on stream enabled, the overlay adds a white call-to-action box with the QR, the short URL and the creator's promo code to every showing (all placements, both canvases). Creators and advertisers can download the QR from their pages. - Promo codes — set a promo suffix on the campaign (e.g.
20) and each accepting creator getsHANDLE20(sponsor_promo_codes, unique per workspace); a campaign-wideALL20exists for the general link. - Conversions (
sponsor_conversions) — recorded three ways, all idempotent onorder_idper campaign: the Record a conversion form (workspace or advertiser), CSV import (code, date, amount, quantity, order_id, note), or the store webhookPOST /hooks/conversions/{token}with{"order_id","code","amount"|"amount_cents","quantity","occurred_at","note"}or an array of them (token per campaign, shown once, stored hashed, rotate from the tracking panel). A conversion with a promo code is attributed to that creator; without one to the campaign. - Reporting — the tracking panel on both campaign pages shows clicks, scans, conversions, conversion rate against clicks + scans, attributed revenue, and the per-creator table (link, code, clicks, scans, conversions, revenue). Advertisers see exactly the same numbers.
Creator opt-in (enable / disable)
Every creator profile has its own overlay URL and an on/off switch at /dashboard/sponsors/overlay (staff and above). While a profile is disabled, its overlay serves nothing and nothing is counted, even if campaigns target it. Campaign targeting is the workspace admin's side of the same gate: a creative appears only when the creator has enabled the overlay and the campaign targets that profile (or all profiles).
The overlay URL contains a random token that is the only credential. Regenerate URL invalidates the old one. Enable/disable and rotation are audited (sponsor_overlay.*).
Overlay
GET /overlay/{token}— transparent 1920×1080 page for OBS. Add as Browser Source, width 1920, height 1080.?preview=1cycles every creative of every eligible campaign every 8 s without counting anything.GET /overlay/{token}/next.json— what to show now:{show, remaining, poll_in, campaign:{id,name,sponsor,sponsor_logo,placement,disclosure}, creative:{id,kind,image_url,headline,body,cta_text,cta_url}}or{show:false, reason, poll_in}. Rate limited (publicbucket).404for unknown tokens. Optionalvisible=0|1: the page's own viewability signal (below).
Selection is deterministic per time slot: for each eligible campaign, slot = floor(now / interval); the creative shows during the first display_seconds of each slot, and the creative index is slot mod weighted-pool-size, so all overlays of a workspace agree and a re-poll during the same showing returns the same answer. When several campaigns collide in a slot, one is chosen round-robin by slot number. Placements: banner (bottom centre), corner (bottom right card), lower_third (bottom left).
Disclosure: the campaign's disclosure_text is rendered on every showing. Creators should also set the platform's branded-content flag (Twitch "Branded Content", YouTube "paid promotion", KICK equivalent) when streaming with sponsors enabled — that is the creator's obligation, the overlay only makes the on-screen part automatic.
Reporting
/dashboard/sponsors shows 30-day totals and the campaign table; each campaign page shows daily impressions, per-creative and per-creator breakdowns, estimated value, and a CSV export of raw impressions (shown_at_utc, seconds, viewers, stream_live, creative_id, creative, profile). Exports are audited.
Where viewers are (country). Each campaign page (workspace and advertiser) has a Where viewers are table and a countries.csv download (/dashboard/sponsors/campaigns/{id}/countries.csv, /advertise/campaigns/{id}/countries.csv, ?days=): per-country counts of video ad starts (sponsor_impressions.country, rolls only), website ad impressions (ad_zone_events.country) and tracked-link clicks / scans (sponsor_link_hits.country), migration 0045, GeoStats::byCountry(). The country is Cloudflare's CF-IPCountry header as Request::$country, honoured only when the request provably came through Cloudflare (Request::countryFrom): nginx adds X-Edge-Peer: $realip_remote_addr — the TCP peer it saw before the real-IP substitution — through the per-domain include deploy/nginx/edge-peer.conf, and the app accepts the country when that peer is in Cloudflare's published ranges (Request::CLOUDFLARE_IPS, override CLOUDFLARE_IPS) or when the direct peer is a trusted proxy (loopback, tests). A client hitting the origin IP directly can send any CF-IPCountry it likes and gets Unknown; Apache's ports are closed in the firewall, so only nginx can set X-Edge-Peer. XX/T1 are dropped; never stored with an address, never finer than country. Countries with fewer than GeoStats::MIN (10) events in the window are folded into Other and requests without a country are Unknown, so no single viewer can be singled out; the raw impression CSV carries no geography. Overlay showings (Twitch / Kick / YouTube viewer counts) have no country and are not in this table; the creator's self-declared region stays on the audience card, labelled as declared. No targeting by country at decision time.
is_live = 0 impressions mean the overlay was running while the live poller did not see the creator live (no connected account, poll lag, or OBS open while offline). Bill on verified-live impressions unless the sponsor agreed otherwise.
Viewability (sponsor_impressions.visible). OBS tells a browser source when it is rendered in the program output (obsSourceActiveChanged / obsSourceVisibleChanged events, OBS 27+); a normal browser tab reports the Page Visibility API. The overlay page sends visible=1 or visible=0 with each poll once it knows, and nothing before (an OBS source that has not fired an event yet, or an unknown host). Impressions carry visible = 1, 0 or NULL (no signal). visible = 0 is never billed: SponsorService::billable() excludes those rows from CPM spend (AdBillingService::accrueSpend), creator earnings (EarningsService::accrue), invoice proposals (InvoiceService::uninvoiced), budget caps and every reported total; the campaign pages show them as "hidden in OBS, not billed" and the CSV export has an overlay_visible column (1, 0, blank). A showing first polled as hidden and then as visible within the same slot is upgraded to visible, never the reverse. Server-side composited overlays (Krunc relay) are burned into the picture and recorded with visible = 1. Viewer counts remain the live poller's 2-minute snapshot: a spike or drop between polls is not seen, so viewer-impressions are an estimate by construction.
Creator earnings, split and payouts
Each accepted creator on a campaign earns at the creator rate — the consent row's override, else the campaign's creator_rate_type / creator_rate_cents — shown on their offer before they accept:
- CPM: per 1,000 of their own viewer-impressions, accrued every minute by
sponsors.accrue_earnings(EarningsService::accrue) with a watermark per consent row (earned_viewer_impressions/earned_cents). Withdrawn or removed creators keep earning for showings already recorded. - Flat: earned once, when the creator's first showing for the campaign is recorded (
flat_earned_at). - Every movement is a signed row in
creator_earningswithbalance_after; the balance is cached oncreator_profiles.earnings_balance_cents.
Split (campaign page, Revenue split): advertiser spend (estimated value at the campaign rate) → creator earnings accrued → platform fee (tenant.limits.fee_percent of spend) → workspace net.
Payouts — two ways, both on /dashboard/sponsors/payouts (admin):
- Pay via Stripe (Stripe Connect, Express accounts). The creator sets up payouts once from My earnings → Set up payouts: the app creates an Express account (
creator_profiles.stripe_account_id) and sends them to Stripe's hosted onboarding (account_links); the return URL syncsstripe_payouts_enabled/stripe_details_submitted/stripe_requirements, and theaccount.updatedwebhook keeps them current (creator notified if payouts get paused). When the creator is ready the admin's Pay via Stripe button creates a transfer from the platform's Stripe balance to the connected account (EarningsService::payViaStripe, idempotency key per admin action,creator_payouts.stripe_transfer_idunique), records the payout and debits the ledger. Stripe then pays the creator's bank on their schedule (daily by default).transfer.reversedre-credits the ledger once and marks the payoutreversed. The platform balance is funded by advertiser top-ups (separate charges and transfers), so a transfer fails cleanly — nothing recorded — when the balance is short. - Recorded manually (PayPal, bank, cash): amount ≤ balance, method, reference, date — writes
creator_payoutsand a negative ledger row, auditscreator.payout, notifies the creator. Bonuses and deductions are recorded the same way (creator.earnings_adjusted).
Creators see their ledger, payouts and Stripe status on /dashboard/sponsors/earnings for the profiles they own, with a login link to their Express dashboard.
Setup: enable Connect on the Stripe account (Express), set STRIPE_CONNECT_ENABLED=true, and register a second webhook endpoint at /webhooks/stripe that listens to connected-account events (account.updated) with its signing secret in STRIPE_CONNECT_WEBHOOK_SECRET; the platform endpoint delivers transfer.reversed. Without keys the mock provider stands in (tests) and the buttons are hidden. Tests: tests/ConnectPayoutsTest.php.
Workspace payouts (platform → workspace)
Advertiser money for wallet-funded campaigns lands in the platform's Stripe balance, so the platform owes each workspace its net. WorkspacePayoutService keeps a signed ledger per tenant (workspace_ledger, balance cached in tenants.payout_balance_cents):
| Row | When |
|---|---|
revenue (+) | every cent of wallet spend accrued on the workspace's campaigns (AdBillingService::accrueSpend, flat fees on activation) |
platform_fee (−) | tenant.limits.fee_percent of that revenue |
creator_share (−) | creator earnings accrued on wallet-funded campaigns (the platform pays creators directly) |
payout (−) | money sent to the workspace |
adjustment (±) | platform-admin corrections, Stripe transfer reversals |
Invoice-funded campaigns never touch this ledger.
- Owner page
/dashboard/workspace/payouts(owner role): balance, lifetime spend / fee / creator share / paid, ledger, payouts received, and Payouts via Stripe setup — the same Connect Express onboarding creators use, on atenants.stripe_account_idaccount (WorkspacePayoutService::startOnboarding, status synced on return and hourly byconnect.sync_accounts). - Platform admin page
/admin/payouts: every workspace with revenue, fee, creator share, balance and Stripe status, the platform's available Stripe balance, Pay via Stripe (transfer, idempotent per admin action,workspace_payouts.stripe_transfer_idunique, status re-synced right before the transfer), record wire / PayPal payouts, adjustments. Owners are notified.transfer.reversedcredits the ledger once and marks the payoutreversed.
Tests: tests/WorkspacePayoutsTest.php.
Stripe Connect implementation notes
Stripe requires Accounts v2 (POST /v2/core/accounts, JSON, Stripe-Version: 2026-08-26.dahlia) for new Connect integrations on this account; v1 POST /accounts is rejected. StripePaymentProvider creates recipient accounts (configuration.recipient.capabilities.stripe_balance.stripe_transfers), Express dashboard, platform as fees_collector / losses_collector, onboarding via POST /v2/core/account_links (use_case.type = account_onboarding, configurations ["recipient"]), status via GET /v2/core/accounts/{id}?include=configuration.recipient&include=requirements (ready = stripe_transfers.status === "active"; outstanding = requirements.entries[].description), login links and transfers via v1. v2 accounts emit thin events to a v2 event destination rather than v1 account.updated: a destination (POST /v2/core/event_destinations, events_from: ["@accounts"], event_payload: thin) sends v2.core.account[configuration.recipient].capability_status_updated, v2.core.account[requirements].updated, v2.core.account_link.returned and v2.core.account.closed to /webhooks/stripe, signed with its own secret (STRIPE_EVENTS_SECRET). Thin events carry only the account id (related_object.id); WebhookProcessor::thinEvent re-pulls the account and updates the owning creator profile or workspace. Status is also synced on return from onboarding, hourly while pending, and immediately before every transfer; the v1 account.updated handler stays for accounts that emit it. Tests always use the mock provider (Store/boot.php), never real keys.
Stripe Invoicing (invoice-funded campaigns)
For campaigns with funding = invoice, the workspace bills the sponsor through Stripe from the campaign page (Invoices panel, admin+): amount defaults to the value delivered and not yet invoiced (InvoiceService::uninvoiced: estimated value at the campaign rate minus sponsor_campaigns.invoiced_cents), due in 14 days by default, optional memo. The service finds or creates a Stripe Customer for the sponsor's contact email (sponsors.stripe_customer_id), creates a send_invoice Invoice with one line (campaign, viewer-impressions, CPM), finalizes and sends it — Stripe emails the sponsor a hosted pay-online page. Rows live in sponsor_invoices (unique stripe_invoice_id, watermark viewer_impressions_covered); the campaign's invoiced_cents / invoiced_viewer_impressions advance so the next invoice proposes only the delta. Advertisers see their invoices with the pay link on the portal campaign page.
Webhooks: invoice.paid marks the invoice paid once and, because the money is now in the platform balance, credits the workspace ledger (revenue − platform fee − the creator earnings accrued on that campaign that the platform now pays, tracked by workspace_creator_share_cents); owners and the sponsor are notified. invoice.payment_failed notifies owners. invoice.voided / invoice.marked_uncollectible (and Void on the panel, which also voids at Stripe) release the amount to be billed again. Subscription invoices from the store are untouched (the handler falls through when the invoice is not a sponsor invoice). Tests: tests/InvoicingTest.php.
Money: advertiser budgets (prepaid wallets)
Each sponsor has one wallet (sponsor_wallets) and an immutable ledger (sponsor_ledger, signed cents, balance_after on every row). A campaign's funding is either invoice (the workspace bills manually; nothing touches the wallet) or wallet (advertiser proposals default to this):
| Movement | Type | Who / when |
|---|---|---|
| Card top-up | topup_stripe | Advertiser on /advertise/billing → Stripe Checkout (sponsor_topups row, metadata.kind = sponsor_topup). Credited only by the checkout.session.completed webhook; the unique stripe_session_id on the ledger makes a replayed or duplicated event a no-op. The success page never credits. Receipt by email + in-app notification. |
| Wire / check | topup_manual | Platform admin on /admin/advertisers (amount, method, reference, note) — audited as admin.sponsor_funds; the advertiser is notified. |
| Correction | adjustment | Platform admin, debit direction on the same form. |
| CPM spend | spend | Scheduler task sponsors.accrue_spend (every minute): for wallet-funded CPM campaigns, viewer-impressions recorded since the last charge × rate, idempotent via the campaign's charged_viewer_impressions / charged_cents watermark. |
| Flat fee | spend | Charged once when a wallet-funded flat campaign is activated (flat_charged_at); activation is refused if the balance is short. |
| Refund | refund | charge.refunded webhook for a top-up debits the refunded amount (once, tracked per payment intent). |
Wallet-funded CPM campaigns are not eligible while the sponsor's balance is ≤ 0 (SponsorService::eligibleCampaigns); the last minute of impressions may overshoot slightly below zero. When a wallet hits zero the advertiser and the workspace owners get one "budget depleted" notification (re-armed by the next credit).
Limits: card top-ups between $20 and $10,000, USD only. Money lands in the platform's Stripe account (single account, like the store); paying workspaces and creators out is manual until Stripe Connect is decided (CLAUDE.md open decisions). invoice-funded campaigns keep working as before: the estimated value is what the workspace invoices.
Where to look: /advertise/billing (advertiser: balance, add funds, ledger), /dashboard/sponsors (workspace: balance per sponsor; campaign form: Funded by), /admin/advertisers (platform: all sponsors, balances, record wires/checks, recent movements). Tests: tests/AdBillingTest.php.
Tables
sponsors, sponsor_campaigns (+ placement_portrait, creator_rate_type, creator_rate_cents, landing_url, show_qr, promo_suffix, conversion_token_hash, schedule, daily_budget_cents, priority, approval_status, approval_note, submitted_by), sponsor_creatives (+ video_url, video_url_portrait, poster_url, fit, video_meta, vast_url, duration_ms, vast_checked_at, vast_error, rendition_status, approval_status, approval_note, submitted_by), sponsor_impressions (+ visible, surface, context, visitor_hash), sponsor_roll_events, sponsor_users, live_samples, creator_audience_daily, creator_earnings, creator_payouts, workspace_ledger, workspace_payouts, sponsor_invoices, sponsor_campaign_creators, sponsor_links, sponsor_link_hits, sponsor_promo_codes, sponsor_conversions (all tenant-scoped); ad_zones (platform-wide); sponsors.vast_access (+ vast_requested_at, vast_decided_at, vast_decided_by, vast_note); invites.sponsor_id; and three columns on creator_profiles: sponsors_enabled, overlay_token, overlay_orientation.