Webority.Ads.Meta
0.3.1
dotnet add package Webority.Ads.Meta --version 0.3.1
NuGet\Install-Package Webority.Ads.Meta -Version 0.3.1
<PackageReference Include="Webority.Ads.Meta" Version="0.3.1" />
<PackageVersion Include="Webority.Ads.Meta" Version="0.3.1" />
<PackageReference Include="Webority.Ads.Meta" />
paket add Webority.Ads.Meta --version 0.3.1
#r "nuget: Webority.Ads.Meta, 0.3.1"
#:package Webority.Ads.Meta@0.3.1
#addin nuget:?package=Webority.Ads.Meta&version=0.3.1
#tool nuget:?package=Webority.Ads.Meta&version=0.3.1
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.
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, hashing, EF mappings, Schema/*.sql, AddWeborityAds<TContext>() |
Webority.Ads.Google |
Data Manager offline click conversions (service-account token), Google Ads lead form parsing and key check, AddGoogleAds() |
Webority.Ads.Meta |
Conversions API for CRM, Lead Ads Graph retrieval, webhook signature and handshake checks, AddMetaAds() |
Webority.Ads.LinkedIn |
Conversions API events, Lead Gen Form retrieval through the Lead Sync API, webhook signature and challenge checks, AddLinkedInAds() |
Webority.Ads.Microsoft |
Offline conversions through the Campaign Management REST API (matched on msclkid), refresh-token access token, AddMicrosoftAds() |
Webority.Ads.AspNetCore |
AddAdLeadWebhooks() and MapAdLeadWebhooks(): the lead endpoints and the background intake worker |
Webority.Ads.Ai |
Lead screening: each lead judged spam, unqualified or qualified before the product's handler sees it, the verdict kept on the AdLead, a lead_qualified conversion for a confident qualified answer, AddWeborityAdsAi<TContext>() (see Lead screening) |
Webority.Ads.Testing |
FakeAdPlatformConversionClient, FakeAdLeadDetailsSource, RecordingAdLeadHandler, AdFixtures (payloads, and Meta and LinkedIn signers) |
Webority.Ads references Webority.Analytics at exactly 0.5.1 for MarketingTouch.
Wiring a product
// Startup.ConfigureServices
services.AddDbContext<AppDbContext>(...);
services.AddDbContextFactory<AppDbContext>(...); // the dispatcher and the intake write through their own contexts
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 Ads:Microsoft conversions when present
.AddAdLeadWebhooks(); // only on the head that receives lead webhooks
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
// AppDbContext.OnModelCreating
modelBuilder.ApplyWeborityAds();
Copy content/schema/AdConversion.sql and content/schema/AdLead.sql from the package into the product's Scripts/ (idempotent, guarded).
Configuration
{
"Ads": {
"DefaultPhoneCountryCode": "91",
"Google": {
"CustomerId": "7239470170",
"ServiceAccountEmail": "ads@project.iam.gserviceaccount.com",
"ServiceAccountPrivateKey": "<Key Vault>",
"ConversionActions": { "signup_completed": "1234567890" }
},
"Meta": {
"DatasetId": "123456789",
"AccessToken": "<Key Vault>",
"LeadEventSource": "Product name",
"ConversionActions": { "signup_completed": "Qualified lead" }
},
"LinkedIn": {
"AccessToken": "<Key Vault>",
"ConversionActions": { "signup_completed": "104500001" }
},
"Microsoft": {
"DeveloperToken": "<Key Vault>",
"CustomerId": "250000001",
"AccountId": "140000001",
"ClientId": "<Entra app id>",
"ClientSecret": "<Key Vault>",
"RefreshToken": "<Key Vault>",
"ConversionActions": { "signup_completed": "Signup completed" }
},
"GoogleLeadForms": { "WebhookKey": "<Key Vault>" },
"MetaLeadAds": { "AppSecret": "<Key Vault>", "VerifyToken": "<Key Vault>", "PageAccessToken": "<Key Vault>" },
"LinkedInLeadForms": { "ClientSecret": "<Key Vault>", "AccessToken": "<Key Vault>" }
}
}
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 token (the app's OAuth flow) and updates
Ads:LinkedIn:AccessTokenandAds:LinkedInLeadForms:AccessTokenbefore they lapse.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.
Queuing a conversion
await _adConversions.EnqueueAsync(new AdConversionRequest
{
ConversionKey = AnalyticsEvents.SignupCompleted,
SubjectReference = account.PublicId,
OccurredDateTimeUtc = now,
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
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. LastError holds the platform's refusal or a curated line; an unexpected exception goes to the log in full, never to the row.
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 is acknowledged and stores nothing. 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. 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.
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. - 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's platform lead id, never the internal id), 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(platform lead id), so with the judgment ledger registered every row carries the lead's platform lead id inSubjectRef. 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.
Upgrading a database that already has the tables
Re-run the package's Schema/AdLead.sql (or copy its new batches into a dated script in the product's Scripts/). Every batch is guarded: the COL_LENGTH batches add the lead screening columns (QualityLabel, QualityConfidence, ScreenRecommendation, JudgedDateTimeUtc, all nullable) to an AdLead table created before they shipped, and do nothing where they exist. 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.
Gates
dotnet build Webority.Ads.slnx -v q --nologo -m:4
dotnet test Webority.Ads.slnx --no-build --nologo -v q
Run these by hand before pushing; CI only packs and publishes on merge to main.
Release status
Published on nuget.org: 0.1.0, 0.1.1, 0.1.2, 0.2.0, 0.3.0 and 0.3.1. Directory.Build.props VersionPrefix holds 0.3.1, the current released version. 0.2.0 adds the Webority.Ads.Microsoft package; a product that does not call AddMicrosoftAds() changes nothing.
- 0.3.1: lead screening writes only its own conversion rows (it no longer saves the product's whole database context), names a lead by its platform lead id instead of the internal id, screens a lead that has no platform lead id, and counts a duplicate qualified conversion from two racing deliveries as already queued (BUG-12348). No schema or API change.
- 0.3.0: built on Webority.Analytics 0.9.0 (the qualified-lead key is its
AnalyticsEvents.LeadQualified) and Webority.Ai 0.9.0; the table scripts sit atcontent/schema/in the package. NewWebority.Ads.Aipackage, lead screening (see Lead screening).AdLeadgainsQualityLabel(AdLeadQuality),QualityConfidence,ScreenRecommendation(AdLeadScreenRecommendation),JudgedDateTimeUtcandRecordJudgment; every product on the new version runs the newAdLead.sqlbatches first (see Upgrading a database that already has the tables). No API removed.
| 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.10)
- Webority.Ads (>= 0.3.1)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Webority.Ads.Meta:
| 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.