Skip to main content

Ads & Revenue Share — Technical Reference

← Back to Ads & Revenue Share

Where this lives

Backend

Frontend (web)

Frontend (admin)

Technical implementation checklist

  • profileAdsEnabled / activeAdPlacements (client) — drive whether and what the slot renders
  • recordAdImpression / recordAdClick — viewer taken from auth context; revenue estimated server-side, never client-declared
  • myAdEarnings — a creator's own monthly ledger
  • adminAdSettings / adminUpdateAdSettings — read/update split, CPM, thresholds (validated 0–100 / non-negative)
  • adminAdPlacements / adminCreateAdPlacement / adminUpdateAdPlacement — placement CRUD
  • adminAdEnabledUsers / adminSetUserAdsEnabled — beta allowlist
  • adminSettleAdRevenue — settle a period from the server-side estimate
  • adminAdRevenueSummary / adminAdRevenueLedger — money generated + per-creator ledger
  • adminIngestAdRevenueReport / adminImportAdRevenueFromGam — settle from real revenue (CSV / GAM)
  • adminCreditUserCoins — manual coin transfer/adjustment
  • GAM Reporting API client wired inside gam-report.service.js#fetchCreatorRevenueimplemented this session: real REST calls against the Ad Manager API (Beta) via googleapis's GoogleAuth, not a stub. Still needs real GAM_SERVICE_ACCOUNT_KEY/GAM_NETWORK_CODE credentials to go live in production — see the caveat below.
  • Site-wide AdSense loader in app/layout.tsx — the <script> Google's AdSense setup flow asks you to paste into <head> of every page (account verification + Auto ads), added as a beforeInteractive next/script gated by NEXT_PUBLIC_ADSENSE_CLIENT

GraphQL surface

Client schema (/web/graphql):

type Query {
activeAdPlacements: [AdPlacement!]!
profileAdsEnabled(userId: ID!): Boolean!
myAdEarnings(limit: Int, offset: Int): [AdRevenueLedgerEntry!]!
}

type Mutation {
recordAdImpression(input: RecordAdEventInput!): AdEventResult!
recordAdClick(input: RecordAdEventInput!): AdEventResult!
}

input RecordAdEventInput { placementId: ID!, profileUserId: ID! }

Admin schema (/admin/graphql, all admin-prefixed, super_admin only):

type Query {
adminAdSettings: AdSettings!
adminAdPlacements: [AdPlacement!]!
adminAdEnabledUsers: [AdBetaUser!]!
adminAdRevenueSummary(period: String!): AdRevenueSummary!
adminAdRevenueLedger(period: String!, limit: Int, offset: Int): [AdRevenueLedgerAdminEntry!]!
}

type Mutation {
adminUpdateAdSettings(input: AdSettingsInput!): AdSettings!
adminCreateAdPlacement(input: AdPlacementInput!): AdPlacement!
adminUpdateAdPlacement(id: ID!, input: AdPlacementUpdateInput!): AdPlacement!
adminSetUserAdsEnabled(userId: ID!, enabled: Boolean!): Boolean!
adminSettleAdRevenue(period: String!): AdSettlementResult!
adminIngestAdRevenueReport(period: String!, entries: [AdRevenueReportEntryInput!]!): AdSettlementResult!
adminImportAdRevenueFromGam(period: String!): AdSettlementResult!
adminCreditUserCoins(userId: ID!, amount: Int!, reason: String): CoinCreditResult!
}

Query/mutation routing between the two schemas is by name convention — fields matching /^admin[A-Z]/ are exposed only on /admin/graphql (see api/server.js#scopeSchemaToAdmin). That is why the admin panel uses adminAdPlacements rather than the client-only activeAdPlacements.

Configuration (system_setting row)

Ad config lives on typed columns of the single system_setting row (read via payout-settings.access-service.js, not the key/value settings manager). Money values are in micros.

ColumnMeaningDefault
ads_enabledGlobal on/offtrue
ads_creator_share_percentCreator's cut of gross (0–100)55
ads_coins_per_currency_unitCoins credited per 1 currency unit of creator share100
ads_min_credit_coinsMinimum coins before a creator is credited50
ads_impression_cpm_microsEstimated revenue per 1,000 impressions (micros)0
ads_click_value_microsEstimated revenue per click (micros)0

Edit these from /adsAd settings, or directly via adminUpdateAdSettings.

Data model

ad_placement (a slot) → ad_event (impressions/clicks, with server-estimated estimated_revenue_micros) → aggregated at settlement into ad_revenue_ledger (one row per creator per period: impressions, clicks, gross_revenue_micros, creator_share_micros, platform_share_micros, creator_share_coins, status, coin_transaction_id). The allowlist is the ads_enabled boolean on user.

Setup & operations

1. Apply the migrations

npm run migrate
npm run migrate:test # if you run the test database

This creates the ad tables, the ads_enabled user column, and the ad config columns on system_setting.

2. Choose an ad provider / payout model

  • Creator fund (coins) — no external ad account required. Configure ads_impression_cpm_micros / ads_click_value_micros, run monthly settlement, and creators are paid in coins. Simplest; works today.
  • Google AdSense — set NEXT_PUBLIC_ADSENSE_CLIENT (e.g. ca-pub-…) in frontend-nextjs, and create a placement with provider adsense whose providerSlotId is the AdSense data-ad-slot. Note: sharing AdSense earnings with third parties is against policy — use the coins/creator-fund model for the split. Setting this variable also activates the site-wide loader in layout.tsx (see below) — AdSense account verification requires that regardless of whether the profile ad slot / revenue-share is used at all.
  • Google Ad Manager + MCM — the compliant per-creator revenue route. Set NEXT_PUBLIC_GAM_NETWORK_CODE in frontend-nextjs, create a placement with provider admanager whose providerSlotId is the GAM ad unit path. The slot tags each impression with a creator key-value = the profile owner's id, so GAM reporting attributes revenue per creator.

3. Configure the placement and beta users

In /ads:

  1. Placements → create an active placement with surface: profile (ads only render when an active profile placement exists).
  2. Ad settings → set the split and CPM/click values.
  3. Beta allowlist → search creators by username and add them.

4. Environment variables

VarAppPurpose
NEXT_PUBLIC_ADSENSE_CLIENTfrontend-nextjsAdSense publisher id; enables AdSense rendering on the profile slot and the site-wide verification/Auto-ads loader in app/layout.tsx
NEXT_PUBLIC_GAM_NETWORK_CODEfrontend-nextjsGAM network code; enables GPT rendering
GAM_NETWORK_CODEbackendGAM network code for the Reporting API
GAM_SERVICE_ACCOUNT_KEYbackendService-account key (path or JSON) for the Reporting API
GAM_CREATOR_KEYbackendCustom targeting key name (default creator)

Without the NEXT_PUBLIC_* vars the slot shows a neutral house placeholder. Without the backend GAM_* vars, adminImportAdRevenueFromGam returns a catchable gam.not_configured and you use CSV import instead.

5. Settle a period

  • Estimated: /adsMonthly settlement → enter YYYY-MMRun settlement. Aggregates events, splits, credits coins.
  • Real (CSV): export a GAM report dimensioned by the creator key-value with revenue (micros), impressions and clicks. In /adsImport real revenue, paste rows identifier, grossMicros[, impressions, clicks] (identifier is auto-detected as a user id when it's a UUID, else a username) → Ingest CSV & settle.
  • Real (API): once GAM_* is set and the Reporting client is wired in gam-report.service.js#fetchCreatorRevenue, use Import from Google Ad Manager to pull and settle in one click.

Settlement is idempotent per period: creators already credited are skipped. Unresolved report rows are counted as skipped.

6. View money & pay out manually

  • /adsRevenue → pick a period to see gross / platform / creator totals (currency units) and a per-creator ledger. Each row's Transfer coins opens a modal prefilled with the computed creator-share coins — the amount is editable.
  • /adsManual coin transfer → search any user and transfer an arbitrary coin amount with a reason. Both go through the normal wallet (transactionType: 'reward', relatedType: 'admin_manual') and feed the cashout flow.

The GAM Reporting API client

gam-report.service.js#fetchCreatorRevenue(period) is the single integration point, and is really implemented as of this session (previously a deliberate stub that threw gam.report_client_unavailable). There's no googleapis (npm) discovery client for Ad Manager — it only covers legacy ad products (adexchangebuyer, dfareporting, adsense, ...) — so this talks directly to the modern REST API (Ad Manager API, Beta, https://admanager.googleapis.com/v1), authenticated with google.auth.GoogleAuth from the already-installed googleapis package (the same service-account pattern used by services/iap/google-iap.service.js). The report itself is an async, job-based flow per the Beta API:

  1. POST networks/{net}/reports — create the report definition
  2. POST networks/{net}/reports/{id}:run — start a run (long-running operation)
  3. GET networks/{net}/operations/reports/runs/{opId} — poll until done
  4. GET networks/{net}/reports/{id}/results/{resultId}:fetchRows — paginated row fetch

dimensioned by the network's custom targeting key matching GAM_CREATOR_KEY (resolved to its numeric id via networks/{net}/customTargetingKeys, then passed as ekvDimensionKeyIds), with metrics AD_SERVER_ALL_REVENUE (gross revenue, already in micros), AD_SERVER_IMPRESSIONS and AD_SERVER_CLICKS. importFromGam then feeds the returned [{ creatorId, grossMicros, impressions, clicks }] rows straight into ingestRevenueReport, which reuses the split/credit logic — GAM money is already in micros, so no conversion is needed.

It still needs real GAM_SERVICE_ACCOUNT_KEY/GAM_NETWORK_CODE credentials to go live in production — scaffolded and unit-tested against mocks, not yet exercised against a live GAM network. The file's own header comment flags one honest caveat worth carrying over here: the Ad Manager API is in Beta and Google's reference docs did not fully enumerate every Dimension/Metric enum value at the time this was written. The EKV_0_VALUE dimension constant (paired with ekvDimensionKeyIds) and the field used to match a custom targeting key's human-readable name (checked as displayName/keyName/name) are the best-documented mapping available, but were not verified end-to-end against a live network. If the live API rejects a request, check the Ad Manager API reference for the current enum names and adjust the constants at the top of the file.

A read-only testConnection() (auth + custom-targeting-key resolution, no report actually run) backs the ads group's connectivity test on the Environment Status page.