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

ThingWhat it is
SponsorThe advertiser (name, site, logo, contact). Workspace-scoped; plan limit limits.sponsors.
CampaignA 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.
CreativeAn 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.
AdvertiserA person at the sponsor with a login to the self-serve portal (sponsor_users). Sees only that sponsor's campaigns.
ImpressionOne 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:

RuleEffect
live_onlyOnly while the live poller reports the creator live (otherwise the overlay may show it whenever OBS is open).
min_viewersOnly when the creator's current live viewers are at or above this number.
max_per_hourAt most N showings per hour per creator (0 = only the cadence limits it).
days, hoursUTC day-of-week list and hour windows, e.g. Fri–Sat 18:00–23:00.
daily_budget_centsCPM 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 = pending until 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 — AdvertiserAccess resolves the strongest access per sponsor: direct advertiser or agency owner/manager = write; direct viewer or agency analyst = read-only. Read-only seats can open everything but every write action in AdvertiserController (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 (invites row with role = 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 a from/to window, 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):

  1. A direct sponsor campaign eligible for the page's creator (same rotation as the overlay, preview mode, nothing counted).
  2. Otherwise, if feature flag ads.network is on and the zone is enabled, the network tag renders inside a sandboxed <iframe srcdoc> sized to the zone (AdProvider → TagProvider; providers squren and tag both take a pasted HTML tag). Remove allow-popups from the zone's sandbox flags to block popunders.
  3. Otherwise nothing.

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.

Audience profile per creator

What an advertiser buys against, per creator (AudienceService, Profiles module):

  • Measured viewership — the live poller writes one live_samples row per live account per poll (every 2 min). The hourly audience.rollup task folds samples into creator_audience_daily per platform plus an all row = 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 in creator_profiles.audience; validated against AudienceService::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 in creator_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.

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:

  1. 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.
  2. 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 default creator_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.
  3. SponsorService::eligibleCampaigns serves only accepted rows; the campaign's profile_ids column 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.

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's landing_url (falls back to the sponsor website) with utm_source, utm_medium=stream|qr, utm_campaign, utm_content=<handle> appended. Hits are recorded in sponsor_link_hits, de-duplicated per visitor (IP + UA + day) for 10 minutes, and crawler user agents are ignored. ?s=qr marks a scan.
  • QR codes — /go/{code}/qr.svg renders 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 gets HANDLE20 (sponsor_promo_codes, unique per workspace); a campaign-wide ALL20 exists for the general link.
  • Conversions (sponsor_conversions) — recorded three ways, all idempotent on order_id per campaign: the Record a conversion form (workspace or advertiser), CSV import (code, date, amount, quantity, order_id, note), or the store webhook POST /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=1 cycles 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 (public bucket). 404 for unknown tokens.

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.

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.

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_earnings with balance_after; the balance is cached on creator_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 syncs stripe_payouts_enabled / stripe_details_submitted / stripe_requirements, and the account.updated webhook 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_id unique), records the payout and debits the ledger. Stripe then pays the creator's bank on their schedule (daily by default). transfer.reversed re-credits the ledger once and marks the payout reversed. 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_payouts and a negative ledger row, audits creator.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):

RowWhen
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 a tenants.stripe_account_id account (WorkspacePayoutService::startOnboarding, status synced on return and hourly by connect.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_id unique, status re-synced right before the transfer), record wire / PayPal payouts, adjustments. Owners are notified. transfer.reversed credits the ledger once and marks the payout reversed.

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):

MovementTypeWho / when
Card top-uptopup_stripeAdvertiser 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 / checktopup_manualPlatform admin on /admin/advertisers (amount, method, reference, note) — audited as admin.sponsor_funds; the advertiser is notified.
CorrectionadjustmentPlatform admin, debit direction on the same form.
CPM spendspendScheduler 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 feespendCharged once when a wallet-funded flat campaign is activated (flat_charged_at); activation is refused if the balance is short.
Refundrefundcharge.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, rendition_status, approval_status, approval_note, submitted_by), sponsor_impressions, 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); invites.sponsor_id; and three columns on creator_profiles: sponsors_enabled, overlay_token, overlay_orientation.