Web Intent — Developer
Web Intent / IP Match & Engage — Technical Documentation
Audience: Developers, architects, and technical support engineers. Scope: The complete pipeline, from the JavaScript widget through company identification, visit persistence, the nightly qualification automation, and prospect email finding.
1. Systems involved
| # | Repository / Solution | Role | Framework |
|---|---|---|---|
| 1 | sopro-personalisation | Widget (hq.js) + IpLookup endpoint | .NET Core 3.1 |
| 2 | soproprospecting (SoPro.sln) | WebsiteWidgetController + WidgetPageVisit + Hangfire scheduler | .NET Framework |
| 3 | anonymous-ip (AnonymousIP.sln) | Company identification (Cache → Internal → Clearbit) | .NET 6 |
| 4 | sopro-sodastream-core | Queue consumers: persistence, nightly automation, email finding | .NET |
2. Architecture
3. Stage‑by‑stage detail
Stage 1 — The client‑side widget (sopro-personalisation)
1a. Bootstrap script — Controllers/ApiController.cs → GetWidgetCode(string Domain, int PropertyTypeId, int ClientId). Clients paste a small <script> that creates window.outbase, reads the _obid visitor id from the query string or the __outbasep cookie, and loads https://sopro-personalisation.azurewebsites.net/hq.js.
1b. Widget rendering — Controllers/ScriptController.cs → Hq(key) / HqSpa(key) → HqInternal(key, isSpa). Validates the integration token (PropertySettings), applies security checks (bot detection, IP blacklist, rate limiting), evaluates inclusion/exclusion rules, and builds an HqScriptViewModel exposing two tracking URLs:
| ViewModel property | Purpose |
|---|---|
PageVisitUrl / PageVisitHearthBeatUrl | Known visitor (email click) tracking |
AnonymousPageVisitUrl / AnonymousPageVisitHearthBeatUrl | Anonymous (Web Intent) tracking |
IsAnonymousIpActive | Feature flag enabling anonymous tracking |
1c. Client‑side branching — Views/Script/CoreSPA.cshtml:
if (!IsBot && _obid != undefined && _obid != null) {
// TYPE 1 — EMAIL CLICK (known prospect): POST to PageVisitUrl
} else if (!IsBot) {
// TYPE 2 — ANONYMOUS IP (Web Intent): POST to the IpLookup endpoint
}
Data collected: page URL + query string, User‑Agent (browser/OS/device), referrer, timestamps, client IP (resolved server‑side), and _obid when present. Heartbeats measure engagement (email path ~5s; anonymous path 5s → 10s → 15s → 30s → 60s back‑off).
Stage 2A — Known visitor / email‑click path (soproprospecting)
Controller: API/SoPro.API/Controllers/WebsiteWidgetController.cs → PageVisit(PageVisitPostViewModel model) — POST /WebsiteWidget/PageVisit.
- Resolve client IP from
HTTP_X_FORWARDED_FOR→REMOTE_ADDR→UserHostAddress; captureUser-Agent. - Map the POST body into a
PageVisitViewModel. - Look up the prospect:
_prospectMailService.GetProspectEmailByWidgetId(WidgetGuidId).- Found → set
ProspectEmailId, geo‑locate the IP, persist via_websiteWidgetService.PageVisit(...)intoWidgetPageVisit, then_prospectIntentTrackingService.TrackGoals(...). - Not found → return
false(visit rejected).
- Found → set
Entity: SoProEntities/WidgetPageVisit.cs (table WidgetPageVisit). Key columns: PageVisitId (Guid, PK), VisitId, WidgetGuidId (_obid), ProspectEmailId (FK), WebSiteWidgetClientId, PageUrl/QueryString, Created/LastTimeSeen/TimeSpent/Tick, IpAddress, Browser/OS/Device/DeviceType, Referrer/ReferrerSource/ReferrerProvider, Country/Region/City, IsArchived.
The email‑click path is self‑contained and does not enter the AnonymousIP company‑detection pipeline.
Stage 2B — Anonymous visitor path (sopro-personalisation)
Endpoint: Controllers/ScriptController.cs → IpLookup([FromBody] IPLookupModel model) — POST /Script/iplookup.
- Validates the page URL. If credits are exhausted (
IsAfterLimitTracking), performs light tracking and returns an emptyProspect. - Calls the AnonymousIP API:
GET {AnonymousIPApiUrl}/api/getCountryAndCompanyByIP
?apiKey={AnonymousIPApiKey}
&ipAddress={model.IPAddress}
&clientSideSearchId=NULL
&clientSideClientId={model.ClientId}
- Deserializes into
IPDetectionResponse/IPDetectionCompany; decorates withClientId,ClientWidgetId,IpAddress,PageUrl,PageVisitId,Created. - Builds the
Prospectreturned to the widget: ifSoproDataCompanyId > 0populateProspectCompany; ifCompanyNameis empty setprospect.Company.IsUnknown = true. - Enqueues the visit:
await _queueService.AddMessage("intenttrackervisit", new MessageModel {
QueueId = (int)SoProQueueEnum.IntentTrackerVisit,
Message = JsonConvert.SerializeObject(_compositeCompany),
SearchTypeId = (int)SoProQueueSearchTypeEnum.IntentTrackerVisit
});
- Returns the
Prospectso the widget can personalise the page in real time.AnonymousIPApiUrl/AnonymousIPApiKeycome fromappsettings.json/secrets.
Stage 3 — Company identification (anonymous-ip, .NET 6)
Controller: WebAPI/Controllers/AnonymousIPController.cs, route prefix [Route("api")].
| Endpoint | Purpose |
|---|---|
GET /api/getCountryAndCompanyByIP | Synchronous single‑IP lookup (used by IpLookup) |
POST /api/createJob | Asynchronous batch lookup (list of IPs → Azure queue) |
Service chain (Services/MainService.cs → GetCountryAndCompanyByIpAddress):
var client = _ClientAppService.GetClientApp(clientToken); // 1. validate token
var result = await RunSingleSearch(client, ssModel, null); // 2. find company
result.CountryFromIPRange =
_LocationService.GetCountryByIpAddress(ssModel.IPAddress); // 3. add country
return result; // SingleSearchCallbackResult
Provider resolution (Services/FindingService.cs → FindCompany) iterates providers from ClientApp.FindersOrder and stops at the first usable result:
foreach (var finder in clientApp.FindersOrder.Order.Split(','))
{
var provider = _AnonymousIPProviderFactory.Create(byte.Parse(finder), price, configs);
var providerResult = await provider.FindCompany(model, search);
if (providerResult?.IsUsable == true) return providerResult;
}
Providers (Services/AnonymousIPProviderFactory.cs):
| Id | Provider | Description |
|---|---|---|
| 0 | Cache | Fast lookup of previously resolved IPs |
| 1 | InternalData | Internal IP→company DB, enriched via SoproData |
| 2 | Clearbit | Clearbit Reveal API (https://reveal.clearbit.com/v1/companies/find?ip=) — consumes credits |
Result (Models/SingleSearchCallbackResult): CompanyName, CompanyEmailDomain, CompanyWebsite, CompanySize, CompanyIndustry, LinkedinCompanyId, LinkedinHandle, SoproDataCompanyId, CountryFromIPRange, IsUsable.
Stage 4 — Persist the anonymous visit (sopro-sodastream-core)
Consumer: SoProQueue/Consoles/SoProQueueConsumer/SoProQueueConsumer/Partials/TaskIntentTrackerVisit.cs (intenttrackervisit queue):
var obj = JsonConvert.DeserializeObject<IPDetectionCompany>(param.Message);
if (string.IsNullOrEmpty(obj.CompanyEmailDomain) && string.IsNullOrEmpty(obj.CompanyName))
await _soProQueueIntentTrackerService.ProcessUnknownVisit(obj); // → PageVisitAnonymousUnknown
else
await _soProQueueIntentTrackerService.ProcessVisit(dapperPageVisit, obj); // → PageVisitAnonymous + WebIntentCompanies
Persistence: Dapper/DapperSopro/DapperSopro/DapperWebsiteWidgetService.cs → AddAnonymousPageVisit(v, obj):
- Look up an existing
WebIntentCompaniesrow by(EmailDomain, ClientId, SoproDataCompanyId):- Exists →
UPDATE WebIntentCompanies SET TotalNumberOfVisits += 1, LastVisit = @LastVisit. - New →
INSERT, derivingLeadStatus(Not engaged / Engaged / SQL / MQL) andStageStatus(New / Active engagement / Opportunity for reengagement) fromProspectEmailhistory.
- Exists →
- Always
INSERTa detailedPageVisitAnonymousrow.
Tables:
PageVisitAnonymous— one row per resolved anonymous visit:PageVisitId,SessionId,ClientId,ClientWidgetId,IpAddress,Country,Company,CompanyNameClean,CompanyNameFromProvider,SoproDbCompanyId,SoproDataCompanyId,LinkedInCompanyId,Website,EmailDomain,CompanySize,Industry,PageVisited,LeadStatus,SessionDurationTotalSeconds,VisitDate, device/referrer fields,IsArchived.PageVisitAnonymousUnknown— one row per visit with no company:ClientId,ClientWidgetId,IpAddress,Country,PageVisited,VisitDate, optional device/referrer fields.WebIntentCompanies— aggregation of unique companies per client/widget, deduplicated on(ClientId, ClientWidgetId, Company, EmailDomain, SoproDataCompanyId). TracksTotalNumberOfVisits,LastVisit,LeadStatus,StageStatus, plus the full Pelias geographic hierarchy. This is the source table the nightly automation reads from.
Stage 5 — Nightly qualification automation
5a. Scheduler — soproprospecting/.../Services/HangFireJobServiceDB.cs:
// InitDatabaseProcedures()
RecurringJob.AddOrUpdate(() => _AddWebAutomationDaily(), Cron.Daily(0, 1)); // 00:01 UTC daily
_AddWebAutomationDaily(): finds every CampaignWebIntentTargeting where IsDeleted == false && TurnOnAuto == true; creates one WebIntentAutomation per targeting (LastVisit = yesterday, PerformBackDate = true); enqueues one message per record onto webintentautomation (SoProQueueEnum.WebIntentAutomation = 42) with the record Id.
5b. Consumer — SoProQueueConsumer/Partials/TaskWebIntentAutomationDaily.cs:
Company qualification — IsWiCompanyQualifiedSimple(targeting, company, allIndustries, allCompanySizes):
| Filter | Rule |
|---|---|
| Company size | If targeting specifies sizes, CompanySize must be one of them (exact match). |
| Industry / group | Qualifies if the industry matches a targeted industry OR a targeted industry group (logical OR). |
| Location | Parsed from CompanyLocation JSON. Must match at least one Included location (name or Pelias id: country / macro‑region / region / locality / continent) and match no Excluded location (excluded match vetoes). |
Additional gating: companies are skipped when StageStatus == "Active engagement" or ProspectCount == 0. Telemetry counters (CompaniesIdentified, CompaniesQualified, CompaniesQualifiedWithProspects, ProspectCount, …) are updated throughout.
Prospect assembly & verification:
_dapperSoProMasterDBAdapter.GetProspects(targeting, SoproDataCompanyId)returns candidate prospects.- The company is upserted via
_dapperCompanyService.UpsertByCampaignIdAndEmailDomain. - A
DapperSourceAndEngageQueuedProspectsrow (statusPending) is built per prospect, assigning anEmailProfileIdin round‑robin (SelectNext). - Prospects are batched (20 per call) and sent to
dataVerificationAdapter.IPMatchAndEngageDataVerification(IPMatchDto). Items notVerifiedare markedVerificationFailed. - Rows are persisted; verified ids are enqueued onto
ipemailfinder(SoProQueueEnum.IPEmailFinder= 43).
Key service fields: _dapperWebIntentAutomationService, _dapperIntentTrackerTargetingService, _dapperWebIntentCompanyAutomationDailyService, _dapperSoProMasterDBAdapter, _dapperCompanyService, _dapperSourceAndEngageQueuedProspectsService, _soProQueueService.
Stage 6 — Prospect email finding (sopro-sodastream-core)
Consumer: SoProQueueConsumer/Partials/TaskIPEmailFinder.cs (ipemailfinder queue). For each SourceAndEngageQueuedProspects id with status Pending:
- Fetch full prospect detail from SoProMasterDB.
- Enforce a max of 20 prospects per company domain per campaign.
- Build a
SimpleSearchModeland call the EmailFinder 2.0 nuget (FindEmailWithNewEmailFinder_Nuget). - On success, create a
DapperProspect(statusNeworVerifydepending onWebIntentVerifyDays) and queue it for email composition (generative templates).
A formerly anonymous website visitor is now a contactable prospect — completing the IP Match and Engage loop.
Goals — real‑time client notifications (both paths)
Separately from the nightly automation, every page visit is evaluated in real time against a set of goals. When a goal is met, Sopro can send an alert email to the client (the sales owner and/or a configured list of colleagues). The same three goal types apply to both the known/email‑click path (per prospect) and the anonymous/Web Intent path (per company).
The three goal types:
| Goal | Fires when… | Config field |
|---|---|---|
| Goal URL (certain pages) | The visitor views a configured high‑intent page | Urls + ProspectIntentTrackingUrls |
| Returning Interest (repeat visitor) | The visitor returns after N days | VisitorsReturningAfterDays |
| Multiple Visits | The visitor makes N separate visits (≥ 4h apart) | VisitorsReturningAfterTimes |
URL matching supports ExactMatch / Contains / StartsWith plus Inclusion / Exclusion rules. A 12‑hour cooldown applies per URL goal and a 24‑hour throttle blocks duplicate goals per prospect/company/IP.
Configuration lives on the per‑client ProspectIntentTracking entity (soproprospecting/.../SoProEntities/ProspectIntentTracking/ProspectIntentTracking.cs), edited via the Web Intent portal (sopro-sodastream-core/.../Portal.Web/Controllers/WebSiteCatcherController.cs). Key fields: IsProspectIntentGoal, IsCompanyIntentGoal, IsEngagedCompanyGoals, IsQualifiedCompanyGoals, IsInstantAlert (vs IsDailySummary / IsWeeklySummary), Urls, VisitorsReturningAfterDays, VisitorsReturningAfterTimes, and the per‑goal IsAlertingOwner* / IsAlertingOthers* / AlertOthers* recipients. Global kill‑switch: GeneralSettings.ProspectIntentGoalEmailNotifications.
Path A — Prospect goals (known visits): WebsiteWidgetController.PageVisit() → ProspectIntentTrackingService.TrackGoals() → ProcessWebGoal() evaluates the three goals, writes a ProspectIntentTrackingGoal row, and (if IsInstantAlert + IsProspectIntentGoal) SendNotification() emails the prospect's owner + AlertOthers* using template 206 (ProspectWebVisitInstantAlert) or 182 (white‑label).
Path B — Company goals (anonymous visits): TaskIntentTrackerVisit → DapperWebsiteWidgetService.ProcessWebGoals() evaluates the same goals against the company's IP history, writes a CompanyIntentTrackingGoal row (flags IsEngagedCompanyGoal / IsQualifiedCompanyGoal), and (if IsInstantAlert + IsCompanyIntentGoal) emails the sales owners (qualified + engaged) + AlertOthers* using template 207 (EngagedCompanyWebgoal) or 208 (QualifiedCompanyWebgoal).
| Aspect | Prospect goals | Company goals |
|---|---|---|
| Visitor | Known (_obid) | Anonymous (IP → company) |
| Entry point | TrackGoals | ProcessWebGoals |
| Goal record | ProspectIntentTrackingGoal | CompanyIntentTrackingGoal |
| Emailed to | Prospect owner + AlertOthers* | Qualified + engaged owners + AlertOthers* |
| Templates | 206 / 182 | 207 / 208 |
| Master switch | IsProspectIntentGoal | IsCompanyIntentGoal |
4. Queue reference
| Queue name | Enum (SoProQueueEnum) | Producer | Consumer | Payload |
|---|---|---|---|---|
intenttrackervisit | IntentTrackerVisit | ScriptController.IpLookup | TaskIntentTrackerVisit | Serialized IPDetectionCompany |
webintentautomation | WebIntentAutomation (42) | HangFireJobServiceDB._AddWebAutomationDaily | TaskWebIntentAutomationDaily | WebIntentAutomation.Id |
ipemailfinder | IPEmailFinder (43) | TaskWebIntentAutomationDaily | TaskIPEmailFinder | SourceAndEngageQueuedProspects.Id |
intenttrackerexclusion | IntentTrackerExclusion | ExclusionService.AddExclusions (when domain is excluded) | TaskIntentTrackerExclusion.ProcessIntentTrackerExclusions | Serialized ExclusionQueueModel |
5. Data model reference
| Table / Entity | Repository (source file) | Purpose |
|---|---|---|
WidgetPageVisit | soproprospecting/.../SoProEntities/WidgetPageVisit.cs | Known (email‑click) page visits |
PageVisitAnonymous | soproprospecting/.../SoProEntities/PageVisitAnonymous.cs | Resolved anonymous visits (company found) |
PageVisitAnonymousUnknown | soproprospecting/.../SoProEntities/PageVisitAnonymousUnknown.cs | Unresolved anonymous visits (no company) |
WebIntentCompanies | sopro-sodastream-core/.../SoProEntities/WebIntentCompanies.cs | Aggregated companies per client/widget (automation source) |
WebIntentCompanyCampaignTargeting | sopro-sodastream-core (join table) | Links companies to campaign targeting |
CampaignWebIntentTargeting | sopro-sodastream-core/.../DapperModels/WebIntent/DapperCampaignWebIntentTargeting.cs | Targeting rules |
WebIntentAutomation | sopro-sodastream-core/.../DapperModels/DapperWebIntentAutomation.cs | One nightly run per active targeting |
WebIntentCompanyAutomationDaily | sopro-sodastream-core/.../DapperModels/DapperWebIntentCompanyAutomationDaily.cs | Per‑run snapshot + telemetry |
SourceAndEngageQueuedProspects | sopro-sodastream-core/.../DapperModels/DapperSourceAndEngageQueuedProspects.cs | Prospects queued for email finding |
Prospect | sopro-sodastream-core | Final contactable prospect |
ProspectIntentTracking | soproprospecting/.../SoProEntities/ProspectIntentTracking/ProspectIntentTracking.cs | Per‑client goal & alert configuration |
ProspectIntentTrackingGoal | soproprospecting/.../SoProEntities/ProspectIntentTracking/ProspectIntentTrackingGoal.cs | Goal met by a known prospect visit |
CompanyIntentTrackingGoal | soproprospecting/.../SoProEntities/CompanyIntentTrackingGoal.cs | Goal met by an anonymous company visit |
6. Source file index
| Concern | File |
|---|---|
| Bootstrap script | sopro-personalisation/Controllers/ApiController.cs (GetWidgetCode) |
| Widget rendering | sopro-personalisation/Controllers/ScriptController.cs (HqInternal) |
| Widget JS | sopro-personalisation/Views/Script/CoreSPA.cshtml |
| Anonymous lookup | sopro-personalisation/Controllers/ScriptController.cs (IpLookup) |
| Email‑click visits | soproprospecting/.../API/SoPro.API/Controllers/WebsiteWidgetController.cs (PageVisit) |
| Company detection API | anonymous-ip/WebAPI/Controllers/AnonymousIPController.cs |
| Detection orchestration | anonymous-ip/Services/MainService.cs, Services/FindingService.cs, Services/AnonymousIPProviderFactory.cs |
| Visit persistence | sopro-sodastream-core/.../Partials/TaskIntentTrackerVisit.cs, Dapper/DapperSopro/DapperSopro/DapperWebsiteWidgetService.cs |
| Nightly scheduler | soproprospecting/.../Services/HangFireJobServiceDB.cs (_AddWebAutomationDaily) |
| Nightly automation | sopro-sodastream-core/.../Partials/TaskWebIntentAutomationDaily.cs |
| Email finding | sopro-sodastream-core/.../Partials/TaskIPEmailFinder.cs |
| Prospect goals + notifications | soproprospecting/.../Services/Services/ProspectIntentTrackingService.cs (TrackGoals, SendNotification) |
| Company goals + notifications | sopro-sodastream-core/.../DapperSopro/DapperWebsiteWidgetService.cs (ProcessWebGoals) |
| Goal configuration UI | sopro-sodastream-core/.../Portal.Web/Controllers/WebSiteCatcherController.cs |
7. Glossary
| Term | Meaning |
|---|---|
| Web Intent | Detecting the company behind an anonymous website visitor and evaluating it for engagement. |
| IP Match and Engage | The full pipeline that turns an anonymous visit into a contactable prospect. |
_obid | Visitor/prospect identifier carried in email links and cookies; presence marks a known visitor. |
| Integration token | The client's widget key (PropertySettings). |
| SoproData / SoProMasterDB | Master company & prospect data source used for enrichment and prospect sourcing. |
| Pelias id (Gid) | Geographic identifier used for hierarchical location matching. |
| Lead status | Not engaged / Engaged / SQL / MQL, derived from prior engagement. |
| Stage status | New / Active engagement / Opportunity for reengagement. |
| Round‑robin email profile | Sending identities are cycled across queued prospects via SelectNext. |
| Goal | A real‑time trigger (specific page / returning after N days / N visits) that alerts the client. |
| Engaged vs. qualified company goal | Engaged = company already has prospects in contact; qualified = company matches Web Intent targeting. |
8. Bot detection
Bots (automated crawlers, scrapers, headless browsers) are filtered out at two layers to prevent them from polluting visit data and wasting look‑up credits.
8a. Client‑side gate — Views/Script/CoreSPA.cshtml
The widget JavaScript makes the first decision:
if (!IsBot && _obid != undefined && _obid != null) {
// TYPE 1 — EMAIL CLICK (known prospect)
} else if (!IsBot) {
// TYPE 2 — ANONYMOUS IP (Web Intent)
}
// If IsBot === true, neither path executes — the visit is silently dropped.
The IsBot flag is set server‑side by HqInternal, which inspects the User‑Agent and other request characteristics before the widget even renders.
8b. Server‑side bot detection — anonymous-ip/Services/Helpers/BotDetectionHelper.cs
For anonymous visits that reach the IP‑lookup stage, a second, more precise check runs inside anonymous-ip. IPCheckingService.ProcessSingleCheck() calls BotDetectionHelper.IsBot(ipCheckerIPCheck) against the IP‑details provider response (from IPApiProvider). This check examines two concrete signals:
Rule 1 — Microsoft datacenter + Microsoft email provider
IsDatacenter == true
AND ASNOrganization contains "Microsoft"
AND EmailProvider is "office 365" or "outlook.com"
→ BotDetectionRule = MicrosoftDatacenter_MicrosoftEmailProvider (enum value 1)
Rule 2 — Google datacenter + Google email provider
IsDatacenter == true
AND ASNOrganization contains "Google"
AND EmailProvider is "gmail"
→ BotDetectionRule = GoogleDatacenter_GoogleEmailProvider (enum value 2)
If either rule matches, the IP check result is marked IsBot = true and the search is stopped immediately — no company look‑up is performed, no credits are consumed, and the visit is discarded.
8c. Additional bot check — known‑visitor path (soproprospecting)
The email‑click path also has its own server‑side bot gate. WebsiteWidgetService.PageVisit() calls AdditionalBotCheck(ipAddress, emailDomain, pageVisitId, clientId) which hits the AnonymousIP API to verify the IP before accepting the visit. If the response returns IsBot == true:
- The bot detection rule (
BotDetectionRuleEnum) is resolved to a friendly name via.ToFriendlyName()(e.g. "Microsoft data center — Microsoft email provider"). - A row is written to the
BotVisitslog table with the IP, reason, page URL, User‑Agent, and referrer. - The visit is rejected (
return false).
8d. Bot detection enums
Defined identically in both anonymous-ip/Utils/Enums/BotDetectionRuleEnum.cs and soproprospecting/Services/Enums/BotDetectionRuleEnum.cs:
| Enum value | Name | Friendly label |
|---|---|---|
| 1 | MicrosoftDatacenter_MicrosoftEmailProvider | Microsoft data center — Microsoft email provider |
| 2 | GoogleDatacenter_GoogleEmailProvider | Google data center — Google email provider |
8e. Data model — IPCheck entity
The bot detection result is persisted on the IPCheck entity (Entities/IPCheck.cs):
| Column | Type | Notes |
|---|---|---|
IsBot | bool? | true if the IP was identified as a bot |
BotDetectionRule | byte? | Which rule fired (1 or 2); null if not a bot |
These columns also flow through to SingleSearchCallbackResult (Models/SingleIPCheckCallbackResult.cs) so that upstream callers can log or report on bot‑filtered traffic.
9. The 5‑second rule — heartbeat & short‑visit cleanup
The widget uses a heartbeat system to measure how long a visitor stays on the page. Short, fleeting visits (likely bots, accidental clicks, or zero‑engagement bounces) are automatically purged.
9a. Heartbeat back‑off schedule
Heartbeats are periodic pings from the browser to the PageVisitHeartBeat endpoint, reporting how many seconds have elapsed since the last ping. The anonymous‑visitor path uses a back‑off schedule to reduce load for long‑staying visitors:
| Ping # | Interval since last ping | Cumulative time |
|---|---|---|
| 1 | 5 seconds | 5s |
| 2 | 10 seconds | 15s |
| 3 | 15 seconds | 30s |
| 4 | 30 seconds | 60s |
| 5+ | 60 seconds | 60s+ |
The known‑visitor (email‑click) path pings every ~5 seconds with no back‑off.
Each heartbeat updates PageVisitAnonymous.SessionDurationTotalSeconds (with a cap at 3,600 seconds / 1 hour) and refreshes WebIntentCompanies.LastVisit.
9b. The 5‑second cleanup — CleanPrivacyPolicyAnonymous
After an anonymous visit is recorded, a delayed cleanup task checks whether the visitor actually engaged. The mechanism is in DapperWebsiteWidgetService.CleanPrivacyPolicyAnonymous(string pageVisitId):
public async Task CleanPrivacyPolicyAnonymous(string id)
{
// Wait 60 seconds before checking — gives heartbeats time to arrive
Thread.Sleep(60000);
var visit = await GetVisit(pageVisitId);
if (visit != null && visit.SessionDurationTotalSeconds <= 5)
{
// Visit lasted 5 seconds or less → purge it
await DeleteVisit(pageVisitId);
}
}
Logic:
- A background task is scheduled when the visit is first recorded (via the
PrivacyPolicyVisitsAnonymousqueue). - It waits 60 seconds — enough time for at least one 5‑second heartbeat to arrive and update
SessionDurationTotalSeconds. - After the wait, it checks
SessionDurationTotalSeconds:- ≤ 5 seconds → the page was never open long enough for even one heartbeat to fire. The visit is treated as non‑engagement and deleted from
PageVisitAnonymous. - > 5 seconds → at least one heartbeat fired, so the visit is kept.
- ≤ 5 seconds → the page was never open long enough for even one heartbeat to fire. The visit is treated as non‑engagement and deleted from
This effectively means: if a visitor doesn't stay on the page for at least 5 seconds, the visit never happened (from the system's perspective). The same principle applies to the known‑visitor path via CleanPrivacyPolicy, which checks Tick <= 1 after a 12‑second wait.
9c. The "5 seconds" parameter in goal alerts
The "DURATION" parameter in goal notification emails is hard‑coded as "5 seconds" — it describes the heartbeat interval, not a visit threshold:
var parameters = new Dictionary<string, string>()
{
{ "DURATION", "5 seconds" },
// ...
};
This value appears in the alert email template to give recipients context about how visit duration is measured.
10. Intent Tracker Exclusions — ProcessIntentTrackerExclusions
When a client creates a domain exclusion in the Sopro portal, the exclusion must cascade into the Web Intent data to prevent excluded companies from ever becoming prospects. This is handled by a dedicated queue consumer.
10a. Trigger — when exclusions are created
Exclusions are entered through the portal or uploaded in bulk. Three services produce the queue message:
| Service | Location | Trigger |
|---|---|---|
ExclusionService.AddExclusions | soproprospecting/Services/Services/ExclusionService.cs | Portal exclusion CRUD |
ExclusionService.AddExclusions | sopro-sodastream-core/.../Portal.Services/Implementations/Exclusions/ExclusionService.cs | Portal exclusion CRUD (core) |
DapperExclusionService.AddExclusions | sopro-sodastream-core/.../DapperSopro/DapperSopro/DapperExclusionService.cs | Bulk/Dapper exclusion operations |
If the excluded value is a domain (not an email address — checked via !exclusionEntity.Email.Contains('@')), the service enqueues:
await AddToQueueAsync(
JsonConvert.SerializeObject(new ExclusionQueueModel {
Domain = exclusionEntity.Email,
CampaignId = exclusionEntity.CampaignId,
ClientId = exclusionEntity.ClientId,
IsCompetitorExclusion = (subType == "Competitors")
}),
SoProQueueEnum.IntentTrackerExclusion.ToFriendlyName()
);
Campaign sharing also triggers exclusions: CampaignsSharedService enqueues a model with SharedCampaignId to propagate exclusions between shared campaigns.
10b. The ExclusionQueueModel payload
public class ExclusionQueueModel
{
public string Domain { get; set; } // The domain being excluded (e.g. "competitor.com")
public int? CampaignId { get; set; } // Scope: single campaign
public int? SharedCampaignId { get; set; } // Scope: propagate to shared campaign
public int? ClientId { get; set; } // Scope: all campaigns for a client
public bool IsGlobal { get; set; } // Scope: global (across all clients)
public bool IsCompetitorExclusion { get; set; } // Marks as competitor (not just general)
}
10c. Consumer — TaskIntentTrackerExclusion.cs
The queue consumer (SoProQueueConsumer/Partials/TaskIntentTrackerExclusion.cs) deserialises the model and delegates to DapperWebsiteWidgetService.ProcessIntentTrackerExclusions(model).
10d. Routing — four processing paths
ProcessIntentTrackerExclusions routes to one of four private methods based on the model's scope fields:
Path 1 — ProcessSharedExclusions (shared campaign scope)
Copies exclusion flags from the source campaign's WebIntentCompanyCampaignTargeting rows (where IsExclusion = 1) to the shared campaign's rows for the same WebIntentCompanyIds.
Path 2 — ProcessForGlobal (global / cross‑client scope)
Finds all WebIntentCompanies rows matching the excluded domain (where SoproDataCompanyId > 0), then updates all WebIntentCompanyCampaignTargeting rows linked to those companies across all campaigns — setting IsQualified = 0, IsFlagged = 0, IsExclusion = 1.
Path 3 — ProcessForCampaign (single campaign scope, with retry)
- Resolves the
ClientIdfrom the campaign. - Checks for shared campaigns (via
CampaignsShared) that also share exclusions — builds a list of all related campaign IDs. - Updates
WebIntentCompanyCampaignTargetingfor the excluded domain across all shared campaigns, joining throughWebIntentCompaniesto match byEmailDomain,ClientId, andSoproDataCompanyId > 0. - Retry logic: on failure, logs the error to
ApplicationsErrorLogand retries up to 3 times.
Path 4 — ProcessForClient (client‑wide scope)
Finds all WebIntentCompanies rows matching the excluded domain for the client, then updates all their WebIntentCompanyCampaignTargeting rows to exclusion status.
10e. Effect on the nightly automation
All four paths produce the same end state on WebIntentCompanyCampaignTargeting:
IsQualified = 0
IsFlagged = 0
IsExclusion = 1
IsCompetitorExclusion = (true if competitor subtype)
When the nightly TaskWebIntentAutomationDaily runs, its qualification loop checks IsExclusion — excluded companies are skipped, preventing them from ever producing prospects. This applies retroactively: if a company was already qualified before the exclusion was created, the exclusion queue task will mark it and it won't be processed in future nightly runs.
10f. Source files
| Concern | File |
|---|---|
| Queue consumer entry point | sopro-sodastream-core/.../Partials/TaskIntentTrackerExclusion.cs |
| Queue routing (ConsoleTasks) | sopro-sodastream-core/.../Partials/ConsoleTasks.cs (line ~350) |
| Core processing logic | sopro-sodastream-core/.../DapperSopro/DapperWebsiteWidgetService.cs (ProcessIntentTrackerExclusions + 4 private methods) |
| Service interface | sopro-sodastream-core/.../Interface/IDapperWebsiteWidgetService.cs |
| Queue model | sopro-sodastream-core/.../SharedModels/SoProQueue/Exclusion/ExclusionQueueModel.cs |
| Portal exclusion trigger | soproprospecting/.../Services/Services/ExclusionService.cs |
| Campaign sharing trigger | soproprospecting/.../Services/Services/CampaignsSharedService.cs |
11. Clearbit Credits System
Provider Priority & Credit Consumption
The IP→company lookup runs through providers in priority order (ClientApp.FindersOrder, e.g. "0,1,2"). Only the last provider (Clearbit, id=2) consumes credits:
| Provider Id | Name | Cost | Speed | Fallback |
|---|---|---|---|---|
| 0 | Cache | Free | Instant | Next provider if miss |
| 1 | InternalData | Free | Fast | Next provider if miss |
| 2 | Clearbit | 1 credit | API call (~200ms) | Returns null (no result) |
// FindingService.cs — FindCompany
foreach (var finder in clientApp.FindersOrder.Order.Split(','))
{
var provider = _AnonymousIPProviderFactory.Create(byte.Parse(finder), price, configs);
var providerResult = await provider.FindCompany(model, search);
if (providerResult?.IsUsable == true) return providerResult;
}
return null; // All providers exhausted, no result
Credit Accounting — Account Entity
The Account table in anonymous-ip tracks client credit balances. Credits were historically stored directly on the Account entity (Credits, CreditsSpent, CreditsLeft columns — now commented out), but the current system relies on Clearbit's own credit tracking via the ClearbitAccountId.
API endpoints for credit management:
| Endpoint | Method | Description |
|---|---|---|
/api/updateAccountCredits?apiKey=&email=&clientId=&credits= | GET | Updates the credit balance for a client's Clearbit account |
/api/alignCreditBalanceWithClearbit?apiKey=&email=&clientId= | GET | Syncs the local credit balance with Clearbit's actual balance |
/api/createClearbitAccount?apiKey=&email=&clientId=&clientName= | GET | Creates a new Clearbit account for a client during widget setup |
Credit Exhaustion Flow
When a client runs out of credits, the following chain executes:
ScriptController Logic — Credit Gate
In sopro-personalisation/ScriptController.cs, the HqInternal and IpLookup methods check AreClearbitCreditsSpent:
// HqInternal — early return if no OBID and credits are spent
if (string.IsNullOrWhiteSpace(Request.Query["_obid"])
&& (!model.IsAnonymousIpActive || model.AreClearbitCreditsSpent))
{
return Content(""); // Return empty — no tracking
}
// Later in HqInternal — only call AnonymousIP if credits available
if (model.IsAnonymousIpActive && !model.AreClearbitCreditsSpent)
{
// POST to anonymousIP for company identification
}
// IpLookup — check AfterLimitTracking first
if (model.IsAfterLimitTracking)
{
await AfterLimitTracking(model); // Lightweight tracking, no API call
return Json(new Prospect()); // Return empty prospect
}
// Normal flow — call AnonymousIP API (consumes credit if Clearbit hit)
var url = $"{AnonymousIPApiUrl}/api/getCountryAndCompanyByIP/...";
Email Notification
The TurnOffAnonymousVisits method sends the ClearbitCreditsSpent (id=156) notification to:
- CS owner (Client Service) + their team lead
- Ops owners (all campaigns' ops owners) + their team leads
Parameters: CLIENT_NAME, CS_OWNER_EMAIL, CS_TEAM_LEAD, OPS_OWNERS, OPS_TEAM_LEADS.
12. IsAfterLimitTrackingActive — After-Limit Tracking Mode
Purpose
IsAfterLimitTrackingActive is a graceful degradation flag on PropertySettings. When enabled, the widget continues recording visits but skips all company identification — preserving visit data without consuming any credits.
Database
Migration: 20250227105710_IsAfterLimitTrackingActive — adds IsAfterLimitTrackingActive (bit, default 0) to PropertySettings.
Entity: Entities/PropertySettings.cs:
public bool IsAfterLimitTrackingActive { get; set; }
ViewModel: Models/HqScriptViewModel.cs:
public bool IsAfterLimitTrackingActive { get; set; }
Request model: Models/IPLookupModel.cs:
public bool IsAfterLimitTracking { get; set; }
Client-Side Flow
In Core.cshtml / CoreSPA.cshtml, the widget checks IsAfterLimitTrackingActive from the view model:
// If after-limit tracking is active and no prospect OBID, skip normal IP lookup
if (params.IsAfterLimitTrackingActive && !hasOBID) {
params.IsAfterLimitTracking = true;
postWidgetApi(visitUrl, params, ...); // Still POSTs to /iplookup
}
Server-Side Flow
ScriptController.IpLookup() checks the flag first:
if (model.IsAfterLimitTracking)
{
await AfterLimitTracking(model);
return Json(new Prospect()); // Empty prospect — browser shows nothing
}
AfterLimitTracking() creates a minimal payload and enqueues directly:
private async Task AfterLimitTracking(IPLookupModel model)
{
var compositeCompany = new IPDetectionResponse
{
ClientId = int.Parse(model.ClientId),
IpAddress = model.IPAddress,
PageUrl = model.PageUrl,
IsAfterCreditLimit = true, // ← marks this as after-limit
Referrer = model.Referrer,
DeviceType = model.DeviceType,
OS = model.OS,
Browser = model.Browser,
Device = model.Device
};
await _queueService.AddMessage("intenttrackervisit",
new MessageModel { Message = JsonConvert.SerializeObject(compositeCompany) });
}
Storage — AfterLimitAnonymousRequest
The queue consumer stores these visits in a separate table:
CREATE TABLE AfterLimitAnonymousRequest (
Id INT PRIMARY KEY IDENTITY,
ClientId INT NOT NULL,
IpAddress NVARCHAR(50),
PageVisited NVARCHAR(MAX),
Created DATETIME2 NOT NULL,
Referrer NVARCHAR(MAX),
ReferrerSource NVARCHAR(MAX),
ReferrerProvider NVARCHAR(MAX),
DeviceType NVARCHAR(50),
OS NVARCHAR(50),
Browser NVARCHAR(50),
Device NVARCHAR(50)
);
Cleanup
Hangfire recurring job — RemoveOldUnknownAndAfterLimitVisits() runs every Sunday at 9pm:
RecurringJob.AddOrUpdate(() => RemoveOldUnknownAndAfterLimitVisits(), "0 21 * * 0");
Deletes in batches of 10,000:
PageVisitAnonymousUnknownolder than 90 daysAfterLimitAnonymousRequestolder than 90 days
Summary — Before vs After Limit
| Aspect | Normal Mode | After-Limit Mode |
|---|---|---|
| Trigger | IsAnonymousIpActive = true + AreClearbitCreditsSpent = false | IsAfterLimitTrackingActive = true OR model.IsAfterLimitTracking = true |
| IP→Company lookup | ✅ Cache → Internal → Clearbit | ❌ Skipped entirely |
| Credit consumption | Possible (if Clearbit hit) | ❌ None |
| Visit storage | PageVisitAnonymous + WebIntentCompanies | AfterLimitAnonymousRequest |
| Company identified | ✅ Yes | ❌ No (only IP + page + device) |
| Prospect shown | ✅ Company details in browser | ❌ Empty prospect returned |
| Data retention | Permanent | 90 days (weekly cleanup) |
| Use case | Normal Web Intent operation | Credits exhausted / manual Ops override |