Webority.Ads.Google
0.18.0
dotnet add package Webority.Ads.Google --version 0.18.0
NuGet\Install-Package Webority.Ads.Google -Version 0.18.0
<PackageReference Include="Webority.Ads.Google" Version="0.18.0" />
<PackageVersion Include="Webority.Ads.Google" Version="0.18.0" />
<PackageReference Include="Webority.Ads.Google" />
paket add Webority.Ads.Google --version 0.18.0
#r "nuget: Webority.Ads.Google, 0.18.0"
#:package Webority.Ads.Google@0.18.0
#addin nuget:?package=Webority.Ads.Google&version=0.18.0
#tool nuget:?package=Webority.Ads.Google&version=0.18.0
Webority.Ads
Ad platform integration for Webority products, published as NuGet. Two halves in one family:
- Conversions out. A product queues "this subject reached this conversion" once; the package writes one
AdConversionrow per configured platform in the product's own transaction, and a dispatcher the product hosts sends each row to Google Ads, Meta, LinkedIn or Microsoft Advertising and settles it. - Leads in. Verified webhook endpoints for Meta Lead Ads, Google Ads lead forms and LinkedIn Lead Gen Forms record each lead as an
AdLead(a redelivery is a no-op) and hand it to the product'sIAdLeadHandleroff the request. - Campaigns on the four platforms. Daily statistics per platform, audiences with Customer Match, campaign builds that launch in resumable parts, AI ad copy drafts, and campaign changes a person approves. A weekly optimiser proposes changes from the statistics; it never applies one unless the approver has set auto-apply limits.
The package never owns a DbContext. Its entities live in the product's context, mapped by ApplyWeborityAds(), with the tables created from the SQL the package ships.
Proprietary: for Webority internal use only (see LICENSE).
Packages
| Package | What it carries |
|---|---|
Webority.Ads |
AdConversion, AdLead, IAdConversionQueue, AdConversionDispatcher, AdLeadIntake, IAdPlatformConversionClient, IAdLeadHandler, IAdLeadDetailsSource, campaign statistics (AdCampaignMetricDaily, AdStatisticsBatch, AdCampaignTotal, IAdStatisticsCollector, IAdStatisticsStore, IAdStatisticsReader), audiences (AdAudience, AdAudienceMembership, AdPersonalDataEraser), campaign builds (AdCampaignBuild, AdCampaignBuilds), copy briefs (AdCopyBrief, AdCopyBriefs), AdChangeProposal, IAdChangeFinder, hashing, EF mappings, Schema/Tables/*.sql, Schema/Migrations/*.sql, AddWeborityAds<TContext>() |
Webority.Ads.Google |
Data Manager offline click conversions (service-account token), Google Ads lead form parsing and key check, daily statistics from the Google Ads API, customer lists, campaign writer and builder (Search and YouTube), AddGoogleAds() |
Webority.Ads.Meta |
Conversions API for CRM, Lead Ads Graph retrieval, webhook signature and handshake checks, daily statistics from the Marketing API insights, custom and lookalike audiences, campaign writer and builder, AddMetaAds() |
Webority.Ads.LinkedIn |
Conversions API events, Lead Gen Form retrieval through the Lead Sync API, webhook signature and challenge checks, daily statistics with creative and facet breakdowns from the ad analytics API, DMP segment audiences, campaign writer and builder, AddLinkedInAds() |
Webority.Ads.Microsoft |
Offline conversions (matched on msclkid), Customer Match customer lists, daily statistics, campaign writer and builder, refresh-token access token, AddMicrosoftAds() |
Webority.Ads.AspNetCore |
AddAdLeadWebhooks() and MapAdLeadWebhooks(): the lead endpoints and the background intake worker |
Webority.Ads.Ai |
Lead screening (see Lead screening), AddWeborityAdsAi<TContext>(); the weekly optimiser with its finders and scorer, AddAdOptimiser<TContext>(); approved campaign changes, AddAdChanges<TContext>(); auto-apply limits, AddAdAutoApply<TContext>(); audience sync, AddAdAudienceSync<TContext>(); AI ad copy, AddAdCopy<TContext>() |
Webority.Ads.Admin |
AddWeborityAdsAdmin<TContext>(): the Ads area pages (overview, campaigns, conversions, leads, changes, proposals, builds, audiences, copy, optimiser and its limits) |
Webority.Ads.Insights |
AddInsightsCollection(): runs each ad platform's statistics collector inside the Webority.Insights daily run |
Webority.Ads.Testing |
FakeAdPlatformConversionClient, FakeAdLeadDetailsSource, RecordingAdLeadHandler, AdFixtures (payloads, and Meta and LinkedIn signers) |
Webority.Ads references Webority.Analytics at exactly 0.9.0 for MarketingTouch and AnalyticsEvents, and Webority.Domain.Abstractions at exactly 0.2.0 for IHasPublicId.
Wiring a product
// Startup.ConfigureServices
services.AddDbContext<AppDbContext>(...);
services.AddDbContextFactory<AppDbContext>(...); // the dispatcher and the intake write through their own contexts
services.AddWeborityAiActions<AppDbContext>(_configuration); // approvals for every campaign change (webority-ai)
services.AddWeborityAiContent(); // needed by AddAdCopy only (webority-ai)
services.AddWeborityAds<AppDbContext>(_configuration)
.AddGoogleAds() // registers whatever Ads:Google / Ads:GoogleLeadForms carry
.AddMetaAds() // registers whatever Ads:Meta / Ads:MetaLeadAds carry
.AddLinkedInAds() // registers whatever Ads:LinkedIn / Ads:LinkedInLeadForms carry
.AddMicrosoftAds() // registers whatever Ads:Microsoft carries
.AddAdLeadWebhooks() // only on the head that receives lead webhooks
.AddAdChanges<AppDbContext>() // applies approved campaign changes
.AddAdAudienceSync<AppDbContext>() // proposes audience changes; after AddAdChanges
.AddAdAutoApply<AppDbContext>() // optional: runs only when Ads:AutoApply:Enabled is true
.AddAdOptimiser<AppDbContext>() // the weekly optimiser; needs Ads:Optimiser
.AddAdCopy<AppDbContext>(); // AI ad copy; needs Ads:Copy
services.AddScoped<IAdLeadHandler, ProductLeadHandler>(); // required once AddAdLeadWebhooks is called
// Startup.Configure, inside UseEndpoints
endpoints.MapAdLeadWebhooks(); // GET+POST /webhooks/ads/meta, POST /webhooks/ads/google, GET+POST /webhooks/ads/linkedin
// Admin pages, on the host's MVC or Razor Pages builder
services.AddRazorPages().AddWeborityAdsAdmin<AppDbContext>(viewerPolicy, approverPolicy, ResolveAdsActor); // ResolveAdsActor: the product's Func<HttpContext, AdsAdminActor?> (Webority.Ads.Admin), returning the signed-in person as AdsAdminActor(ActorReference, ScopeKey); null refuses proposing
// AppDbContext.OnModelCreating
modelBuilder.ApplyWeborityAds();
Run every script in content/schema/migrations/ in file-name order through the product's migrator before any collector writes (each is idempotent and guarded: a no-op where its change is already in place). 0004_ExtendAdCampaignMetricDaily.sql adds DataState, widens Conversions to DECIMAL(18,4) and makes Leads nullable; it must run before a host takes a package version that reads those columns. content/schema/tables/AdConversion.sql, AdLead.sql and AdCampaignMetricDaily.sql are the declarative shape for a product that keeps table definitions. The 0.18 scripts 0006 to 0013 are listed under Upgrading a database that already has the tables; a product that keeps declarative table definitions adds the matching columns and tables there too.
Configuration
{
"Ads": {
"DefaultPhoneCountryCode": "91",
"Google": {
"CustomerId": "7239470170",
"ServiceAccountEmail": "ads@project.iam.gserviceaccount.com",
"ServiceAccountPrivateKey": "<Key Vault>",
"ConversionActions": { "signup_completed": "1234567890" },
"DeveloperToken": "<Key Vault>",
"ApiVersion": "v22",
"Statistics": { "Enabled": true, "LagDays": 1, "LookbackDays": 3 }
},
"Meta": {
"DatasetId": "123456789",
"AccessToken": "<Key Vault>",
"LeadEventSource": "Product name",
"ConversionActions": { "signup_completed": "Qualified lead" },
"AdAccountId": "1122334455",
"Statistics": { "Enabled": true, "LagDays": 1, "LookbackDays": 3, "ConversionActionTypes": [ "offsite_conversion.fb_pixel_lead" ] }
},
"LinkedIn": {
"ConversionActions": { "signup_completed": "104500001" },
"AdAccountId": "507000001",
"Statistics": { "Enabled": true, "LagDays": 1, "LookbackDays": 3 }
},
"Microsoft": {
"DeveloperToken": "<Key Vault>",
"CustomerId": "250000001",
"AccountId": "140000001",
"ClientId": "<Entra app id>",
"ClientSecret": "<Key Vault>",
"RefreshToken": "<Key Vault>",
"ConversionActions": { "signup_completed": "Signup completed" },
"Statistics": { "Enabled": true, "LagDays": 1, "LookbackDays": 3 }
},
"GoogleLeadForms": { "WebhookKey": "<Key Vault>" },
"MetaLeadAds": { "AppSecret": "<Key Vault>", "VerifyToken": "<Key Vault>", "PageAccessToken": "<Key Vault>" },
"LinkedInLeadForms": { "ClientSecret": "<Key Vault>", "AccessToken": "<Key Vault>" },
"Optimiser": {
"WindowDays": 28,
"MinimumWastedSpendMinor": { "INR": 500000, "USD": 5000 },
"ScoreUse": "campaign-score",
"MaxConversionDropShare": 0.20
},
"Copy": { "Scope": "<the product's copy scope>", "DraftCount": 4, "WinnerCount": 3 },
"AutoApply": { "Enabled": false }
}
}
Ads:DefaultPhoneCountryCodeis required wheneverAddWeborityAdsis called.- Each platform section registers only when present. A present section with a missing value, or with an empty
ConversionActions, stops the host at startup. - Every head that queues conversions needs the platform sections too. Registration is gated on configuration, so a head without
Ads:Googleregisters no Google client and queues no Google rows. ConversionActionsmaps a conversion key (the fleet analytics event name, e.g.signup_completed, or the product's registered event name) to the platform's identifier: a conversion action id on Google, a CRM event name (lead stage) on Meta, a conversion rule id (digits) on LinkedIn, an offline conversion goal name on Microsoft.- LinkedIn access tokens last 60 days and are not renewed by the package. LinkedIn rotates the refresh token on every use, so a refresh token cannot live in configuration. The product renews the Conversions token (the app's OAuth flow) and serves the current one through
ILinkedInAccessTokenSource, the only path the conversions client takes.Ads:LinkedInhas no token setting: a host that enables it without a registered source stops at startup. The lead forms token staysAds:LinkedInLeadForms:AccessToken, which the product updates before it lapses.ApiVersion(theLinkedIn-Versionheader) defaults to202606on both sections. - Microsoft Advertising setup. Create an offline conversion goal in the account (Conversions, Conversion goals, Offline) and put its exact name in
ConversionActions; Microsoft matches the conversion to that goal by name. Get the developer token from the Microsoft Advertising Developer Portal. Register an app in Microsoft Entra (supported account types: any organizational directory and personal accounts), grant it thehttps://ads.microsoft.com/msads.manageandoffline_accessscopes, and sign in once as a user who can manage the account to obtainRefreshToken. The package exchanges it for an access token (login.microsoftonline.com/common), cached until 5 minutes before it expires, and sendsDeveloperToken,CustomerId,CustomerAccountIdand the bearer token toOfflineConversions/Apply. A conversion with nomsclkidis written Skipped with the reason; Microsoft matches on the click id alone, so no email or phone hash is sent. Ads:LinkedInLeadForms:ClientSecretis the LinkedIn app's client secret: it signs every delivery and answers the webhook challenge.- LinkedIn statistics are off until
Ads:LinkedIn:Statistics:Enabledis true. ThenAds:LinkedIn:AdAccountIdmust be digits,LagDays(0 to 7, default 1) is how many days before the run date the window ends, andLookbackDays(1 to 31, default 3) is how many days it covers. The reporting token comes fromGetReportingAccessTokenAsynconILinkedInAccessTokenSource; a host that enables Statistics without it stops at startup. - Google statistics are off until
Ads:Google:Statistics:Enabledis true. They read the Google Ads API as the same service account, which must be added as a user of the Ads account. ThenDeveloperToken(from the Google Ads API Center, with at least Basic access) andApiVersion(the API version path segment, for examplev22; Google retires each version about a year after release, so move it before the sunset date) are required, andLoginCustomerId(digits) is set when access goes through a manager account.LagDaysandLookbackDayswork as on LinkedIn. Google counts no lead form leads, so its rows carryLeadsnull; its conversions are fractional, kept to four places. - Meta statistics are off until
Ads:Meta:Statistics:Enabledis true. They read the Marketing API insights withAds:Meta:AccessToken, which then needs theads_readpermission on the ad account.AdAccountIdis the account's digits withoutact_.Leadsare lead form leads (onsite_conversion.lead_grouped);ConversionActionTypeslists the insights action types summed intoConversions(empty leaves it at zero). Figures use the account's unified attribution setting, as Ads Manager shows them. - Microsoft statistics are off until
Ads:Microsoft:Statistics:Enabledis true.LagDaysandLookbackDayswork as on LinkedIn, and the figures are read for the account inAds:Microsoft:AccountId. - Optimiser, copy and auto-apply settings.
Ads:Optimiseris required byAddAdOptimiser:WindowDays(7 to 93),MinimumWastedSpendMinor(by ISO code; an unlisted currency is never proposed),ScoreUse(a use underAi:Judgment:Uses), and optionallyFinders(the finder reason codes to run),MaxConversionDropShare(default 0.20) and the finder thresholds.Ads:Copyis required byAddAdCopy:Scope, plusDraftCount,StrategiesandWinnerCountwhen the defaults are not wanted.Ads:AutoApply:Enabledis false by default; the limits themselves are set by an approver, not in configuration. - Customer Match needs the terms accepted. Before any audience upload to Microsoft Advertising, the Customer Match terms must be accepted in the Microsoft Advertising account.
Queuing a conversion
await _adConversions.EnqueueAsync(new AdConversionRequest
{
ConversionKey = AnalyticsEvents.SignupCompleted,
SubjectReference = account.PublicId.ToString(),
OccurredDateTimeUtc = now,
UserDataConsent = AdUserDataConsent.Granted, // the person's recorded choice; Denied when there is none
Email = user.Email,
PhoneNumber = user.PhoneNumber,
Touch = account.Attribution?.LastTouch, // click ids and click time come from the touch
MetaLeadgenId = account.MetaLeadgenId, // when the account came from a Meta lead form
EventId = pixelEventId // when the browser pixel sent the same event
}, cancellationToken);
await _db.SaveChangesAsync(cancellationToken); // the rows commit with the business change
Consent decides what personal data leaves. UserDataConsent is required. With Denied, no email or phone hash is computed or stored: the row matches only on platform-issued ids (click ids, the Meta lead id), and a platform left with nothing to match on skips it. Google also receives the Data Manager consent block (adUserData and adPersonalization) on every conversion. A product with no recorded choice passes Denied.
One row per configured platform. A platform with no conversion action for the key, or nothing to match on, or a conversion it would refuse as too old, gets a Skipped row with the reason, so a missing report is visible rather than silent. A repeat of the same (key, subject) adds nothing.
Sending
Host AdConversionDispatcher.RunAsync on a timer in the product's Function or worker. Several hosts may run it at once: each run first claims up to 50 Pending rows (oldest first) with one conditional update that stamps ClaimedBy and a 5-minute ClaimedUntilDateTimeUtc, sends only the rows it claimed, stops before a send could outlive the lease, and releases what it did not reach. A crashed run's rows come back when its lease lapses. A retryable failure stays Pending for up to 5 attempts, a refusal fails at once. A platform that answers with a retryable failure (throttled, down, a network error) gets no more sends in that run: its remaining rows go back to the pool for the next run while the other platforms' rows carry on. LastError holds the platform's refusal or a curated line; an unexpected exception goes to the log in full, never to the row. A Failed or Skipped row is final until an operator calls AdConversionDispatcher.ReopenAsync(platform, conversionKey, subjectReference), which puts it back in the queue with a fresh allowance of attempts; the next run checks it against the platform rules again, so a Meta row older than 7 days skips again.
Receiving leads
MapAdLeadWebhooks verifies each delivery (Meta: X-Hub-Signature-256 over the raw body; LinkedIn: X-LI-Signature over the raw body; Google: the echoed google_key, hashed and compared in fixed time), stores the AdLead, answers 200, and queues the lead for the background worker. LinkedIn's GET challenge is answered with the HMAC of its challengeCode, and only for a UUID code: the challenge and the delivery signature are the same HMAC under the same secret, so answering any other code would sign a body of the caller's choosing; a deletion notification erases the lead and its conversions. The worker fetches a Meta lead's answers from the Graph API and a LinkedIn lead's from the Lead Sync API (the response, then its form, so each answer is filed under what it asked: standard fields lifted out, other predefined fields under their predefinedField name, custom questions under their label, chosen options by name), calls IAdLeadHandler.HandleAsync, and records the outcome. It works on up to 4 leads at once, each in its own scope, so the handler can run for different leads at the same time. A lead left Received (a restart, a failed attempt) is swept up every 10 minutes; after 5 failed attempts it is Failed, and AdLeadIntake.ReprocessAsync retries it on an operator's word. Google test deliveries are answered and never stored.
Each attempt claims the lead first with the same conditional-update lease (10 minutes), so two processors on one host or two never both call the handler for one attempt. A heartbeat renews the lease every third of it while the handler runs, so a handler slower than 10 minutes keeps its claim and is not started again by another worker's sweep; a renewal that finds the claim already gone (a crash right before it) logs a warning and stops, since the handler is at least once regardless. A handler that throws leaves a curated LastError (the exception type, never its text); the exception itself is logged in full. The handler is still at least once across a crash between its return and the lead being marked handled: key what it creates on AdLead.Id.
Every lead is minted a PublicId (a version 7 Guid, IHasPublicId) when it is created, before it is stored. It is the lead's id anywhere outside the product's own tables: a URL, an admin link, an export, the subject of its qualified conversion and of its AI judgments. Id stays internal, and PlatformLeadId is unique only within its platform.
Campaign statistics in Webority.Insights
Webority.Ads.Insights runs each registered platform's campaign statistics collector inside the Webority.Insights daily run. Call it after the platform packages register:
services.AddWeborityAds<AppDbContext>(_configuration)
.AddGoogleAds()
.AddMetaAds()
.AddLinkedInAds()
.AddMicrosoftAds()
.AddInsightsCollection();
Each platform's rows are stored through IAdStatisticsStore. A platform error is recorded in the Insights run log and writes no rows for that day. Calling it a second time adds nothing.
Lead screening
Webority.Ads.Ai puts an AI judge in front of the product's IAdLeadHandler. Each new lead is screened for instructions aimed at an AI, then classified as Spam, Unqualified or Qualified, and the verdict is kept on the lead (QualityLabel, QualityConfidence, ScreenRecommendation, JudgedDateTimeUtc) before the product's handler runs, so the handler can read it.
A label never drops, delays or suppresses a lead. The product's handler is called for every lead, whatever the label. When the judge fails, is unreachable, or the day's AI spend cap is reached, the failure is logged, nothing is recorded, and the handler still runs.
Setup
services.AddScoped<IAdLeadHandler, ProductLeadHandler>(); // first: screening moves this registration behind itself
services.AddWeborityAi<AppDbContext>(_configuration); // meters and caps the judgments (optional for Jev)
services.AddWeborityAiJudgmentJev(); // or AddWeborityAiJudgmentAgent()
services.AddWeborityAiJudgmentLedger(); // optional: keeps every judgment for review
services.AddWeborityAds<AppDbContext>(_configuration)
.AddGoogleAds()
.AddAdLeadWebhooks()
.AddWeborityAdsAi<AppDbContext>(); // on the head that receives lead webhooks
{
"Ads": {
"Ai": {
"ScreenUse": "lead-screen",
"ClassifyUse": "lead-quality",
"SpamDescription": "Gibberish, test entries, bots, or someone selling to us.",
"UnqualifiedDescription": "A real person we do not serve: job seekers, students, outside our cities.",
"QualifiedDescription": "A business in our market asking for a quote, a demo or a call back."
},
"Google": { "ConversionActions": { "lead_qualified": "1234567891" } }
},
"Ai": {
"Judgment": {
"Uses": {
"lead-screen": { "Provider": "Jev" },
"lead-quality": { "Provider": "Jev", "AutoAccept": 0.9 }
}
}
}
}
- Every
Ads:Aivalue is required, and both uses must be configured underAi:Judgment:Useswith anIAiJudgeregistered; the host stops at startup otherwise.AddWeborityAdsAialso refuses to run before anIAdLeadHandleris registered. - The three descriptions are the product's definition of each label. They go to the judge with every lead, so write what belongs in each and what does not.
- The three labels. Spam: not a real enquiry. Unqualified: a real person the product would not pursue. Qualified: a lead the sales team wants. Only text the screen passes is classified: Review, Block or Skip leaves the lead with the screen's recommendation and no label, for a person to read.
- A lead carries a label only when the judge was sure (an
Autoanswer). An unsure answer leavesQualityLabelandQualityConfidenceempty whileJudgedDateTimeUtcis set, and the answer stays in the judgment ledger for review, so a product filtering on Qualified never picks up a guess. Quality confidence is computed by the library from the answer's probabilities, the same way on every provider. - Only a confident (
Auto) Qualified answer reports a conversion: thelead_qualifiedkey (AnalyticsEvents.LeadQualified, the fleet event from Webority.Analytics), one per lead (the subject reference is the lead'sPublicId, so two leads that share a platform lead id on different platforms each report their own), queued and saved on a context of its own fromTContextbefore the product's handler runs, so nothing else pending on the intake's context is saved with it. Two deliveries racing to queue one lead leave one row: the unique index refuses the second insert and the handler treats that as already queued. Map the key under each platform'sConversionActionslike any other key; a platform without it gets a Skipped row with the reason. An unsure answer, and every Spam or Unqualified answer, reports nothing. The conversion carries the lead's email and phone (hashed by the queue), itsgclid(Google lead forms) and its Meta lead id (Meta). It cannot be proven end to end without live ad accounts: the package tests prove the row is queued once, not that a platform accepts it. - A lead is judged once. A retry after the product's handler threw finds the verdict already on the lead and does not pay for another judgment.
- Judgments are reviewable. Each runs inside
AiJudgmentSubject.Begin(PublicId.ToString()), so with the judgment ledger registered every row carries the lead'sPublicIdinSubjectRef. When the scope has no AI actor, the judgments act asads-lead-screening.
What is sent to the judge
Everything on the lead that says who sent it and what they asked, one line each: full name, email, phone number, company, city, postal code, campaign name, ad name, and every form answer under its question. A line break inside a value is collapsed to a space, so a value cannot pass as a further line. The text is cut to 4,000 characters, never between the halves of a surrogate pair. With the Jev provider it goes to TypeSafe Jev (api.typesafe.ai); with the agent provider, to the product's own model deployment. Approved by Navneet on 04-Oct-2026 for all of it, personal data included.
Campaign statistics
AdCampaignMetricDaily holds one row per day of a campaign on an ad platform, mapped by ApplyWeborityAds(). A row is built with AdCampaignMetricDaily.Reported(...) for a day the platform reported figures for, or AdCampaignMetricDaily.NoData(...) for a day of a listed Active or Paused campaign the platform reported nothing for; DataState says which, so a reader shows "no data", never a fake zero. An IAdStatisticsCollector returns one platform's rows for a run date as an AdStatisticsBatch, one list per grain: campaign days, ad group days, creative days, search term days and breakdown days. IAdStatisticsStore upserts them, so collecting a day again replaces its figures; a NoData row never replaces a Reported one.
IAdStatisticsReader reads at most 93 days at a time, grouped in SQL:
ReadCampaignTotalsAsyncreturns oneAdCampaignTotalper campaign, ordered by spend, withReportedDaysandNoDataDays. Its name, status and currency come from the campaign's latest day in the range.Leadsis null when no day in the range counts them.ReadPlatformDaysAsyncreturns oneAdPlatformDayper ad account and day, ordered by day:Reportedwhen any campaign reported figures that day,NoDatawhen none did. A day no collection ran for has no row.
Every collector reads the window of LookbackDays days ending LagDays before the run date, keeps spend in minor units of the account currency, and fails the run on a failed request or a day row for a campaign its campaign list did not return; the Insights run log records the failure.
| Platform | Registers with | Day in | Leads | Conversions |
|---|---|---|---|---|
AddLinkedInAds() |
UTC | oneClickLeads |
externalWebsiteConversions |
|
AddGoogleAds() |
the account's time zone | null (not counted) | conversions, fractional |
|
| Meta | AddMetaAds() |
the account's time zone | onsite_conversion.lead_grouped |
the sum of ConversionActionTypes |
Approved campaign changes
A campaign change runs only through Webority.Ai.Actions, after a person other than its proposer approves it. The writers are internal, so no product code reaches a write that skips approval.
- Register
AddWeborityAiActions<TContext>(configuration)(webority-ai), the platform calls (AddGoogleAds(),AddMetaAds(),AddLinkedInAds(),AddMicrosoftAds()) andAddAdChanges<TContext>(). - List every kind in
AdChangeKinds.AllunderAi:Actions:KindswithModeApprove; startup fails otherwise. - Build a proposal with a factory, such as
AdChangeProposal.SetDailyBudget(AdPlatform.Google, accountId, campaignId, amountMinor, "INR", evidence), and propose it wrapped in anAdChangeAction:IAiActions.ProposeAsync(AdChangeKinds.For(proposal.Kind), new AdChangeAction(proposal), summary, options, ct).
The creation kinds (CreateCampaign, CreateAd, CreateAudience, AddAudienceMembers, RemoveAudienceMembers and AttachAudience) need an IAdCreativeAssetSource the product registers when the build has creative assets, which supplies the creative bytes; a campaign build with creative assets is refused without it. CreateAd also needs AddAdCopy<TContext>(), the copy the ad is created from.
| Change | Google Ads | Meta | Microsoft | |
|---|---|---|---|---|
| Pause, resume campaign | campaign status | campaign status | campaign status | campaign status |
| Pause, resume ad | ad status | ad status | creative status | ad (ad group required) |
| Daily budget | the campaign's own budget; a shared budget is refused | campaign budget; a budget on ad sets is refused | campaign daily budget | campaign budget; a shared budget is refused |
| Bid | ad group CPC bid (ad group required) | ad set bid (ad set required) | campaign unit cost (no ad group) | ad group bid (ad group required) |
| Negative keyword | campaign negative keyword | not supported | not supported | campaign negative keyword |
| Audience exclusion | not supported | ad set excluded custom audience (ad set required) | not supported | audience exclusion |
Targeting exclusion (placement, age, gender and other breakdowns) is refused per dimension where the platform cannot exclude it: Microsoft refuses placement and member audiences with not_supported.
AdChangeProposal.PauseAd and ResumeAd take the ad group after the campaign: PauseAd(platform, accountId, campaignId, adGroupId, adId, evidence). Pass null for the ad group where the platform has none; Microsoft requires it.
A writer exists only when its platform's ad account settings are present: Google DeveloperToken and ApiVersion, Meta AdAccountId (with an access token that has ads_management), LinkedIn AdAccountId (the product's token source gives an rw_ads token), Microsoft its Ads:Microsoft section. Each change refuses an amount whose currency is not the ad account's currency. Each writer also refuses, before it writes anything, a campaign outside the configured ad account and an ad group or ad set outside the approved campaign: Google and LinkedIn by the account in the request path and a lookup of the ad group's campaign, Meta by reading account_id and campaign_id first.
Each applied change is recorded in the AdChange table (migration 0005) for its four-week outcome check.
Weekly optimiser
AdOptimiser.RunWeeklyAsync drafts campaign changes for a person to approve; it never applies one.
- Register an
IAiJudge(webority-ai:AddWeborityAiJudgmentJev()orAddWeborityAiJudgmentAgent()), the approved changes above, and thenAddAdOptimiser<TContext>(). - Configure
Ads:Optimiseras under Configuration.ads.pause-campaignmust be inApprovemode; startup fails otherwise. - Call
RunWeeklyAsyncfrom the product's weekly job, with the product's system actor set inAiActorContext.
Each run:
- runs each finder whose reason code is listed under
Ads:Optimiser:Finders. The finders and their codes areAdWastedSpendFinder(no_conversions_in_window),AdBudgetShiftFinder(budget_shift),AdBidChangeFinder(bid_change),AdNegativeKeywordFinder(negative_keyword),AdTargetingExclusionFinder(targeting_exclusion) andAdCreativeFatigueFinder(creative_fatigue); - scores each finding through the
ScoreUsejudgment use: the judgment asks whether the finding is worth proposing and what effect it expects. A finding scored Weak is dropped and logged with its reason; - proposes the rest through
IAiActions, wrapped inAdChangeAction, keyed per platform, account, campaign, kind and ISO week, so a change is proposed at most once a week; - judges every change whose 28-day window after the change has ended by yesterday (
AdChange.OutcomeWindowDays). The account is compared over the 28 days before and the 28 days after:Heldwhen cost per conversion fell and conversions did not drop by more thanMaxConversionDropShare, otherwiseDidNotHold; with no conversions on either side,Heldwhen spend fell. Both figures stay on theAdChangerow. A change staysOpenuntil at least 14 days of figures follow it. A change undone by hand isReverted.
Approver decisions and the four-week results are written to IAiJudgmentLedger when the host registers the judgment ledger with AddWeborityAiJudgmentLedger() (webority-ai); without it the optimiser logs a warning and skips them.
Learning loop
IAdOptimiserPerformanceReader reports each month's proposals, decisions and outcomes per kind, so the approver can see how the optimiser's proposals fare. The threshold suggestions read that history and propose a higher minimum spend, a longer window or no change; nothing changes a setting by itself.
Audiences
AdAudienceMembership creates an audience and writes its members through the product's context, storing contact data only as hashes:
var audience = await audiences.CreateDraftAsync(AdPlatform.Meta, accountId, "Trial users", AdAudienceKind.ContactList, null, null, null, null, ct);
await audiences.AddMembersAsync(audience.Value, members, ct); // IReadOnlyList<AdAudienceMemberRequest>
AdAudienceKindisContactList,CompanyListorLookalike. A lookalike needs a source audience on the same platform and account, and the country and ratio.- Per platform: Google takes contact lists (it builds its own similar audiences, so a lookalike is not created); Meta takes contact lists, custom and lookalike audiences, and refuses company lists; LinkedIn takes contact and company lists and refuses lookalikes; Microsoft takes customer lists only (Customer Match, see Configuration), and refuses company lists and lookalikes.
- A platform upload is an approved change:
AdChangeProposal.CreateAudience,AddAudienceMembers,RemoveAudienceMembersorAttachAudience.AdAudienceSyncRunnerproposes the pending ones throughIAiActionsonceAddAdAudienceSync<TContext>()is registered. AdPersonalDataEraserremoves a person's audience members along with their leads and conversions.
Campaign builds
AdCampaignBuilds.CreateDraftAsync(platform, accountId, blueprint, ct) stores a campaign blueprint (AdCampaignBlueprint: name, objective, daily budget in minor units, currency, dates, targeting and ad groups) as a draft build. Launching it is an approved change, AdChangeProposal.CreateCampaign(platform, accountId, buildPublicId, evidence), applied by the change executor once someone other than the proposer approves it.
- The launch saves each part as the platform creates it. A launch that fails part-way retries only its pending parts; a build already Created or Launched is not sent again.
- Assets upload first, each recorded when the platform accepts it, before the campaign is created.
- An ad for a copy variant is proposed separately, into an ad group that exists:
AdChangeProposal.CreateAd(platform, accountId, campaignId, adGroupId, variantPublicId, evidence).
AI ad copy
AddAdCopy<TContext>() drafts ad copy from a brief a person writes. It needs AddWeborityAiContent(), an IAiJudge and AddWeborityAiActions<TContext>() registered first; Ads:Copy is validated at startup.
- Store the brief:
AdCopyBriefs.CreateAsync(platform, accountId, profileVariant, campaignId, adGroupId, name, finalUrl, fields, ct), wherefieldsis anAdCopyBriefFields(goal, audience, one point, angle, call to action, must say, must not say). - Draft a round:
AdCopyDrafter.DraftAsync(briefPublicId, ct)returns aResult<AdCopyRound>, with one variant per configured strategy (Ads:Copy:Strategies). - Propose a variant:
AdCopyPublisher.ProposeAsync(variantPublicId, ct)returns aResult<AiActionProposal>; the variant goes live only when someone approves it.
The WinnerCount best variants by their figures feed the next round.
Auto-apply limits
AddAdAutoApply<TContext>() lets an approved ads.* change run without a person, but only inside a limit an approver has set. It is off until Ads:AutoApply:Enabled is true. Each limit is an AdAutoApplyLimit row, one per kind and currency, with MaxPerWeek and, for a kind that moves an amount, MaxAmountShare or MaxAmountMinor. The approver sets them on the Optimiser limits page; they are not configuration. Startup fails when an auto-applicable kind is not listed under Ai:Actions:Kinds as Reversible and not outward. Negative keywords and targeting exclusions are never auto-applied, because no inverse kind undoes them.
Admin pages
AddWeborityAdsAdmin<TContext>(viewerPolicy, approverPolicy, actor), on the host's Razor Pages builder, adds the Ads area from Webority.Ads.Admin: overview, campaigns, conversions, leads, changes, proposals, builds, audiences, copy, and the optimiser with its limits. The viewer policy guards the whole area; the approver policy guards approve, reject, undo, propose and draft. The actor mapping gives each action its AI actor, and a null result leaves proposing refused. The AI pages answer 404 without IAiActions.
Sign-in health
Each platform registers an IAdTokenHealth; a product reads them all for its admin page. An entry names the configured ad account, or no account (AccountId null) when the platform has none configured, as for a conversions-only sign-in. A refusal seen since the last accepted call wins, whether a conversion send, a lead fetch, a statistics read or a campaign change saw it: LinkedIn reads Expired on a 401 and Refused on a 403; Google reads Refused; Meta reads Refused on a 401, a 403, or a 400 carrying Meta's error code 190 (an expired or revoked token). Otherwise LinkedIn uses ILinkedInAccessTokenSource.GetSignInExpiryDateAsync and reads ExpiringSoon when 14 or fewer days are left; Google and Meta have no expiry date and read Valid. A LinkedIn or Meta lead fetched while the sign-in is refused is kept as Received with the reason and retried, without counting an attempt.
Erasing a person's data and retention
AdPersonalDataEraser (registered by AddWeborityAds) answers an erasure request. EraseAsync(new AdErasureRequest { Email = ..., PhoneNumber = ..., SubjectReferences = [...] }) deletes the person's leads, the conversions hashed from that email or phone on each platform, and the conversions of those subjects and of the erased leads. EraseLeadAsync(platform, platformLeadId) erases one lead and its conversions; a signed LinkedIn deletion notice calls it. Meta and Google lead forms send no deletion notice, so the product calls EraseAsync when a person asks.
Finished rows are trimmed by Webority.Retention. Add both tables to the product's .webority/data-placement.json. The package key marks them as this package's tables, so the data-placement check (gh-actions 4.21.0 or later) needs no table file for them in the product:
{ "table": "dbo.AdConversion", "class": "workflow:30", "finishedAt": "UpdatedDateTimeUtc", "status": { "column": "Status", "finished": ["Sent", "Skipped"] }, "package": "Webority.Ads" },
{ "table": "dbo.AdLead", "class": "workflow:30", "finishedAt": "HandledDateTimeUtc", "status": { "column": "Status", "finished": ["Handled"] }, "package": "Webority.Ads" }
Failed rows are kept for an operator to look at, as storage.md sets out; the erase path still removes them on request. Deleting a sent conversion after 30 days is safe because a product reports each conversion within days, and the platforms dedupe on the event id.
Upgrading a database that already has the tables
Run the package's content/schema/migrations/0001_Baseline.sql through the product's migrator. Every batch is guarded: the COL_LENGTH batches add the lead screening columns (QualityLabel, QualityConfidence, ScreenRecommendation, JudgedDateTimeUtc, all nullable) and PublicId to an AdLead table created before they shipped, and do nothing where they exist. PublicId is added UNIQUEIDENTIFIER NOT NULL with a temporary NEWID() default that gives every existing lead its own value and is dropped in the same batch; the next batch adds the unique index UX_AdLead_PublicId. A product that keeps a declarative table definition adds [PublicId] UNIQUEIDENTIFIER NOT NULL (no default) and the unique index there too. Run it before deploying a head on the new package version, whether or not the head calls AddWeborityAdsAi: the mapping reads the columns on every AdLead query.
Then run 0003_AddAdConversionUserDataConsent.sql. It adds AdConversion.UserDataConsent with Denied for existing rows and clears the email and phone hashes of rows still Pending, so a conversion queued before consent was recorded sends no personal data. Run it before deploying a head on the version that carries it: the mapping reads the column on every AdConversion query.
Then run 0005_AddAdChange.sql. It creates the AdChange table if it is missing. Run it before deploying a head on the version that carries it: the change executor writes a row for every applied change.
Then run 0006_AddAdMetricDepth.sql through 0013_AddAdAutoApplyLimit.sql in file-name order, each once through the product's migrator. They add the conversion value and video views, and the ad group, ad, search term and breakdown statistics (0006); the audience tables (0007); the campaign build and creative asset tables (0008); the public id, proposal and account-figure columns on AdChange (0009); the copy brief and variant tables (0010); the campaign, ad group and ad a conversion is attributed to (0011); the lead outcome columns (0012); and the auto-apply limit table (0013). Every batch is guarded. Run them all before deploying a head on this version: the mapping reads their tables and columns on every query.
Gates
dotnet build Webority.Ads.slnx -v q --nologo -m:4
dotnet test --solution Webority.Ads.slnx --no-build
Run these by hand before pushing; CI only packs and publishes on merge to main.
Versioning
nuget.org lists every published version. Directory.Build.props VersionPrefix holds the last released version until the next release picks its number. A product that does not call AddMicrosoftAds() is unaffected by the Webority.Ads.Microsoft package.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.Http (>= 10.0.13)
- Microsoft.Extensions.Http.Resilience (>= 10.10.0)
- Webority.Ads (>= 0.18.0)
- Webority.Google.Auth (>= 0.1.1)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Webority.Ads.Google:
| Package | Downloads |
|---|---|
|
Webority.Ads.AspNetCore
ASP.NET Core lead-form webhooks for Webority.Ads: verified Meta Lead Ads, Google Ads lead form and LinkedIn Lead Gen Form endpoints that record each lead and hand it to the product's IAdLeadHandler off the request. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.18.0 | 0 | 11/7/2026 |
| 0.17.4 | 0 | 11/7/2026 |
| 0.17.2 | 0 | 11/7/2026 |
| 0.17.1 | 0 | 11/7/2026 |
| 0.17.0 | 0 | 11/7/2026 |
| 0.16.0 | 0 | 11/7/2026 |
| 0.15.0 | 0 | 11/7/2026 |
| 0.14.15 | 0 | 11/7/2026 |
| 0.14.14 | 50 | 11/7/2026 |
| 0.14.13 | 70 | 11/7/2026 |
| 0.14.12 | 66 | 11/7/2026 |
| 0.14.11 | 64 | 11/7/2026 |
| 0.14.10 | 45 | 11/7/2026 |
| 0.14.8 | 134 | 11/6/2026 |
| 0.14.7 | 98 | 11/6/2026 |
| 0.14.6 | 162 | 11/6/2026 |
| 0.14.5 | 76 | 11/6/2026 |
| 0.14.4 | 105 | 11/6/2026 |
| 0.14.3 | 87 | 11/6/2026 |
| 0.14.2 | 86 | 11/6/2026 |