GTM

Lead scoring, ABM, imports and reply handling, intent, expansion playbooks

For a revenue team, not an engineering one. Everything between "somebody might buy this" and "they renewed": scoring the leads, working the accounts, running the outreach, watching the health of the customers you already have.

It leans on the CRM for the records themselves — GTM is the motion, the CRM is the memory — and on Email setup for anything it sends.

The rest of this document is how it is built and configured.

Most everything sits at /api/v1/workspaces/{workspace_id}/gtm/..., aggregated via backend/src/aexy/api/gtm/__init__.py:28-51. Tag: GTM.

Sub-router quick reference#

#Sub-routerPurpose
1providersConfigure data/enrichment providers (Apollo, ZoomInfo, etc.)
2dashboardKPI overview, funnel, recent visitors, pipeline metrics
3icpIdeal Customer Profile templates
4visitorsVisitor session tracking + identification
5complianceConsent, suppression, audit
6scoringLead score CRUD + rescoring
7dedupDuplicate detection + merge
8imports, repliesContact import and reply classification; outreach runs as funnels
9analyticsPipeline, channels, attribution, trends
10alertsEvent-driven alert configs + logs
11routingLead routing rules + SLA tracking
12healthCustomer-health scoring
13expansionUpsell/cross-sell playbooks
14handoffsCS → Sales handoffs
15intentBuying-signal monitoring
16competitorsCompetitive intel + battle cards
17seoSEO audits + content-gap analysis
18abmTarget lists, tiering, stage progression
19webhooksSubscribe external systems to GTM events

Throughout: path snippets are relative to /workspaces/{workspace_id}/gtm. Admin-marked endpoints require can_manage_gtm or equivalent permission.


1. Providers#

File: api/gtm/providers.py. Service: GTMProviderService.

Configures data/enrichment providers (slot-based — one provider per slot at a time).

GET    /providers/available                       all registered providers
GET    /providers                                 configured providers for this workspace
POST   /providers                                 configure a new provider
GET    /providers/{slot}/{provider_name}
PUT    /providers/{slot}/{provider_name}
DELETE /providers/{slot}/{provider_name}

Schemas: GTMProviderConfigCreate, GTMProviderConfigResponse, GTMAvailableProvider (schemas/gtm).

2. Dashboard#

File: api/gtm/dashboard.py. Service: GTMDashboardService.

GET /dashboard/overview?days=30                   KPI overview
GET /dashboard/funnel                             stage-by-stage funnel data
GET /dashboard/recent-visitors?limit=10           recently identified visitors
GET /dashboard/pipeline-metrics                   scoring / routing / outreach / provider-health rollup

Returns aggregated metrics for the home page — read-only, no LLM.

3. ICP Templates#

File: api/gtm/icp.py. Service: ICPTemplateService.

GET    /icp-templates
POST   /icp-templates                             admin
GET    /icp-templates/{template_id}
PUT    /icp-templates/{template_id}               admin
DELETE /icp-templates/{template_id}               admin

Templates define the workspace's notion of a "good fit" account — industry, employee range, revenue, tech stack signals. Used by scoring and ABM.

4. Visitors#

File: api/gtm/visitors.py. Service: VisitorService.

GET  /visitors?page=...&status=...&utm_source=...&search=...
GET  /visitors/{session_id}                       detail with behavioral events
POST /visitors/{session_id}/identify              manual identify or link
POST /visitors/{session_id}/link                  link to existing record

Sessions arrive via the public event-ingestion SDK; identify_visitor_session (Temporal, 2m timeout) matches them to known accounts. Behavioral events (page views, form fills, CTAs) live alongside.

5. Compliance#

File: api/gtm/compliance.py. Service: GTMComplianceService.

GET    /compliance/check?email=...                "am I allowed to send to this address?"
POST   /compliance/consent                        record consent
GET    /compliance/consent/{email}
DELETE /compliance/consent/{email}                revoke
POST   /compliance/suppression                    add to suppression list
GET    /compliance/audit                          consent + suppression audit log

The single source of truth for "can we email this person?" — every outreach send queries /compliance/check first. GDPR-aligned: consent records carry timestamp, source, version.

POST /compliance/erasure also deletes the funnel emails sent to the address, and any sequence rows left from before 0.58.0.

6. Lead scoring#

File: api/gtm/scoring.py. Service: GTMScoringService.

GET  /scoring/overview
GET  /scoring/leads?min_score=...&max_score=...&lifecycle_stage=...&sort=...
GET  /scoring/leads/{record_id}                   score detail with factor breakdown
POST /scoring/rescore/{record_id}                 manual rescore

Scoring runs as the score_lead activity (5m timeout) for individual records; bulk re-score uses batch_score_leads (30m + 5m heartbeat). Scores attach to CRM records as JSONB and feed routing, alerts, and the dashboard funnel. The negative signal counts funnels the contact left by bouncing, complaining or unsubscribing.

7. Deduplication#

File: api/gtm/dedup.py. Service: DedupService.

GET  /dedup/scan?limit=...&record_id=...          find duplicates
POST /dedup/merge                                 merge two records (admin)
GET  /dedup/stats                                 dedup statistics

Match candidates returned with confidence + reason. Merge is destructive — the source row is archived, references rewritten.

8. Funnels, imports and replies#

GTM has no sequences of its own any more. Outreach runs as funnels — canvas automations (see Email marketing → Funnels and Workflows and automations). The /gtm/sequences API, page and Temporal workflow were removed in 0.58.0, and so were the personalization endpoints: what they generated was never used when sending.

What a sequence became#

Each GTM outreach sequence was converted into one automation:

  • Trigger: contact.enrolled — it runs only when a contact is enrolled: by another funnel, by a contact import, or by hand from the funnel's Enrollments tab.
  • Canvas: a straight line. A step's delay became a wait before it. An email step became a marketing funnel email with the sequence's send window (weekdays, its hours, the contact's own timezone first). An email step with A/B variants became an A/B split by the same weights. LinkedIn steps became the LinkedIn canvas actions. SMS steps were dropped: they never sent anything.
  • Exit goals: from the sequence's exit-on-reply, exit-on-bounce and exit-on-unsubscribe settings.
  • Re-entry: "not while active" — a contact is in it once at a time, as before.

An active sequence's funnel is turned on. A paused, draft or archived one's is turned off, and so is any whose canvas fails the publish check (the script says why). Contacts part-way through an active sequence carry on at the same step, after what was left of the delay; their old workflow is stopped first. Contacts in a paused sequence stay paused.

Canvas and inline steps that enrolled contacts in a sequence now enrol them in its funnel.

Nothing converts on its own. After deploying 0.58.0 and running its SQL migrations, an operator runs:

docker exec aexy-backend python scripts/migrate_sequences_to_funnels.py --dry-run
docker exec aexy-backend python scripts/migrate_sequences_to_funnels.py

It needs Temporal (without it, only --dry-run runs), because it stops the contacts' old workflows. It is safe to run again: a converted sequence is recorded in sequence_funnel_migrations and skipped, and a later run moves any contact an earlier one could not. The code is services/sequence_conversion.py.

Import#

File: api/gtm/imports.py. Service: BulkImportService.

POST /import                                      import leads/accounts
POST /import/preview                              check a file before importing
POST /import/async                                large files, via Temporal
GET  /import/async/{workflow_id}                  progress

Pass automation_id to start a funnel for every imported contact. The funnel must be in this workspace and turned on. Its re-entry policy applies, so a contact already in it is skipped rather than failed.

Replies#

File: api/gtm/replies.py. Service: ReplyClassificationService.

POST /replies/classify                            {message_id, reply_text, reply_from?}
POST /replies/{message_id}/action?category=...    run one category's action by hand
GET  /replies/stats?days=30                       classified replies by category

message_id is the funnel email that was answered. An LLM puts the reply in one category: interested, ooo, not_interested, wrong_person, unsubscribe, question or referral. At 0.7 confidence or more it acts:

CategoryAction
interested, not_interested, wrong_person, referralThe contact leaves every funnel they're in (exit reason reply:<category>)
unsubscribeUnsubscribed from all marketing mail, and out of every funnel
ooo, questionRecorded only; the funnel carries on

The result is kept on the funnel email (reply_category, reply_confidence, reply_action). Classifying is a call you make: a reply arriving does not classify itself.

9. Analytics#

File: api/gtm/analytics.py. Service: GTMAnalyticsService.

GET /analytics/pipeline                           lifecycle stage distribution + conversions
GET /analytics/channels                           funnel email: sent, delivered, opens, clicks, replies, bounces
GET /analytics/attribution?model=...              first_touch / last_touch / linear / u_shaped / time_decay
GET /analytics/funnels                            funnel comparison
GET /analytics/trends?period=...                  time-series
GET /analytics/weekly-report                      the weekly digest

All five attribution models supported. No LLM.

Outreach numbers come from funnels: funnel emails and funnel enrolments. "Funnel comparison" shows, per funnel, how many contacts it enrolled in the window, how many are still in, finished, left early, replied or bounced, and how many emails it sent. The dashboard's KPI is "Active funnels".

10. Alerts#

File: api/gtm/alerts.py. Service: GTMAlertService.

GET    /alerts/configs
POST   /alerts/configs                            admin
PUT    /alerts/configs/{alert_id}                 admin
DELETE /alerts/configs/{alert_id}                 admin
GET    /alerts/logs                               delivery history

Alerts trigger on events (visitor identified, score crossed threshold, deal stage changed, …) with optional conditions (JSONB AND/OR). Channel: slack / email / webhook. Delivery dispatches send_gtm_alert (2m).

11. Routing & SLA#

File: api/gtm/routing.py. Service: LeadRoutingService.

GET    /routing/rules
POST   /routing/rules                             admin
PUT    /routing/rules/{rule_id}                   admin
DELETE /routing/rules/{rule_id}                   admin
POST   /routing/route/{record_id}                 apply rules → assign
GET    /routing/assignments?status=...
POST   /routing/assignments/{assignment_id}/reassign
GET    /routing/sla-dashboard                     breach overview

Strategies: round_robin, availability, custom. SLA tracked per assignment via sla_first_response_minutes and sla_follow_up_minutes. check_sla_breaches (5m, scheduled) flags violations.

12. Customer health#

File: api/gtm/health.py. Service: HealthScoringService.

GET  /health/dashboard                            tier distribution + at-risk
GET  /health/scores?health_status=...
GET  /health/scores/{record_id}
POST /health/scores/{record_id}/rescore
POST /health/batch-score                          batch

Score is a weighted sum of five sub-scores: engagement (default 25%), usage (30%), support (20%), NPS (15%), payment (10%). Thresholds: healthy ≥70, at_risk 40-69, critical <20. Trend is derived from score_history JSONB.

Individual scoring: score_customer_health (5m). Bulk: batch_score_customer_health (30m + 5m heartbeat). Drops: detect_health_drops (5m, scheduled).

13. Expansion playbooks#

File: api/gtm/expansion.py. Service: ExpansionPlaybookService.

GET    /expansion/playbooks
POST   /expansion/playbooks                       admin
GET    /expansion/playbooks/{playbook_id}
PUT    /expansion/playbooks/{playbook_id}         admin
DELETE /expansion/playbooks/{playbook_id}         admin
POST   /expansion/playbooks/{playbook_id}/enroll  enroll an account
GET    /expansion/enrollments?status=...
POST   /expansion/enrollments/{enrollment_id}/outcome   converted/lost
GET    /expansion/analytics

Playbooks are step graphs — eligibility check, multi-channel touches, outcome reporting. Triggered by evaluate_expansion_triggers (5m); steps advance via advance_expansion_step (2m).

14. Handoffs#

File: api/gtm/handoffs.py. Service: HandoffService.

POST /handoffs                                    create CS → Sales handoff
GET  /handoffs?status=...&assigned_to=...
GET  /handoffs/{handoff_id}
POST /handoffs/{handoff_id}/accept
POST /handoffs/{handoff_id}/decline
POST /handoffs/{handoff_id}/convert               → deal
GET  /handoffs/analytics                          acceptance + conversion rates

handoff_type: expansion / upsell / cross_sell. Status flow: pending → accepted/declined → in_progress → converted/lost. SLA on acceptance: sla_accept_minutes with sla_breached boolean.

15. Intent signals#

File: api/gtm/intent.py. Service: IntentSignalService.

GET    /intent/signals?signal_type=...&intent_strength=...
GET    /intent/signals/{signal_id}
POST   /intent/signals                            admin (manual insert)
POST   /intent/signals/{signal_id}/dismiss        admin
GET    /intent/config
PUT    /intent/config                             admin
GET    /intent/summary

Config holds monitored_domains, job_title_keywords, tech_keywords, competitor_names, plus per-signal-type signal_weights (JSONB). Strengths: weak/medium/strong.

Collection: collect_intent_signals (30m + 5m heartbeat, scheduled) pulls third-party data; match_intent_signals_to_records (10m) joins signals to known accounts.

16. Competitors#

File: api/gtm/competitors.py. Service: CompetitorIntelService.

GET    /competitors
POST   /competitors                                          admin
GET    /competitors/{competitor_id}
PUT    /competitors/{competitor_id}                          admin
DELETE /competitors/{competitor_id}                          admin
GET    /competitors/changes
POST   /competitors/changes/{change_id}/acknowledge          admin
GET    /competitors/{competitor_id}/battle-card              get or generate
PUT    /competitors/{competitor_id}/battle-card              admin

check_competitor_changes (30m + 5m heartbeat, scheduled) scrapes competitor pages for pricing/feature/messaging changes. generate_battle_card (LLM_RETRY, 10m) synthesizes a one-pager comparing your product vs the competitor.

17. SEO & content gap#

File: api/gtm/seo.py. Service: SEOAuditService / ContentAnalysisService.

# SEO audits
POST /seo/audits                                  admin → Temporal dispatch
GET  /seo/audits
GET  /seo/audits/{audit_id}
GET  /seo/audits/{audit_id}/pages
GET  /seo/audits/{audit_id}/history

# Content gap analysis
POST /content/analysis                            admin → Temporal dispatch
GET  /content/analysis
GET  /content/analysis/{analysis_id}

run_seo_audit (15m + 30s heartbeat) runs the audit. run_content_gap_analysis (LLM_RETRY, 30m + 5m heartbeat) compares your content footprint to keyword gaps vs competitors.

18. ABM (Account-Based Marketing)#

File: api/gtm/abm.py. Service: ABMService.

GET    /abm/lists
POST   /abm/lists                                            admin
GET    /abm/lists/{list_id}
PUT    /abm/lists/{list_id}                                  admin
DELETE /abm/lists/{list_id}                                  admin
GET    /abm/overview                                         total accounts + tier distribution + engagement
GET    /abm/accounts?target_list_id=...&tier=...&stage=...
GET    /abm/accounts/{account_id}                            contact + engagement metrics
POST   /abm/accounts/{account_id}/stage                      admin
POST   /abm/accounts/{account_id}/assign-campaign            admin

Lists can be is_dynamic=true — criteria-based, re-evaluated by refresh_dynamic_abm_lists (10m, scheduled). Account tiers: tier_1/tier_2/tier_3. Stages: unaware/aware/engaged/...

Engagement recompute: recalculate_abm_engagement (30m + 5m heartbeat). It counts funnel emails sent to and replied to by the account's contacts.

19. Outbound webhooks#

File: api/gtm/webhooks.py. Service: GTMWebhookService.

GET    /webhooks?is_active=...
POST   /webhooks                                  with event subscriptions
GET    /webhooks/{webhook_id}
PUT    /webhooks/{webhook_id}
DELETE /webhooks/{webhook_id}
GET    /webhooks/{webhook_id}/deliveries
POST   /webhooks/{webhook_id}/test                send sample payload

Funnel events are funnel.enrolled, funnel.completed, funnel.exited and funnel.replied. They replaced sequence.enrolled, sequence.completed and sequence.replied; the 0.58.0 migration renamed existing subscriptions. The payload carries automation_id, automation_name, run_id, record_id, status and exit_reason, plus message_id and subject for funnel.replied. They are sent after the change that caused them commits, so a rolled-back enrolment sends nothing.

Delivery uses the shared deliver_webhook activity with WEBHOOK_RETRY (6 attempts, 1m → 1h backoff). See webhooks.md for HMAC signing protocol.


Models (12 files)#

All under backend/src/aexy/models/gtm_*.py. Key shapes:

Outreach has no GTM model of its own: funnels are CRM automations (models/crm.py) and their emails are AutomationEmailMessage (models/funnel_email.py). The gtm_outreach.py models were removed in 0.58.0; their tables stay until every deployment has run the conversion script.

gtm_intent.py#

ModelHighlights
IntentSignalsignal_type, intent_strength, confidence_score, signal_data JSONB, is_dismissed
IntentSignalConfigmonitored_domains, job_title_keywords, tech_keywords, competitor_names, signal_weights JSONB

gtm_alerts.py#

ModelHighlights
GTMAlertConfigevent_type, conditions JSONB, channel_type, channel_config, message_template, is_active
GTMAlertLogdelivery_status, error_message, sent_at

gtm_routing.py#

ModelHighlights
GTMRoutingRulepriority, is_active, conditions JSONB, strategy, assignee_pool, sla_first_response_minutes, sla_follow_up_minutes
GTMLeadAssignmentrecord_id, assignee_id, assigned_at, first_response_at, sla_breached, sla_breach_at

gtm_health.py#

ModelHighlights
GTMHealthScoretotal_score, five sub-scores (0-100), health_status, trend, scoring_factors JSONB, score_history JSONB
GTMHealthConfigweights (defaults engagement 25, usage 30, support 20, NPS 15, payment 10), thresholds

gtm_abm.py#

ModelHighlights
ABMTargetListname, criteria JSONB (industries, employee/revenue ranges), is_dynamic, is_active, account_count
ABMAccounttier, stage, engagement_score, contact counts, activity metrics (emails, meetings, deals)

gtm_handoff.py#

ModelHighlights
GTMHandoffhandoff_type, title, context, estimated_value, products JSONB, signals JSONB, status, sla_accept_minutes, sla_breached

Plus#

  • gtm_webhook.py — webhook configs + deliveries
  • gtm_seo.py — audit results + per-page scores
  • gtm_competitor.py — competitor records + tracked changes
  • gtm_compliance.py — consent records + suppression lists
  • gtm_expansion.py — playbook templates + enrollments
  • gtm_content.py — content-gap analysis records

Services (15 files)#

Under backend/src/aexy/services/:

ServiceDomain
gtm_service.pyProvider config, dashboard aggregation, ICP CRUD
gtm_analytics_service.pyPipeline/channel/attribution/funnel comparison/trends
gtm_alert_service.pyAlert configs, event matching, dispatch
gtm_compliance_service.pyConsent, suppression
gtm_webhook_service.pyWebhook CRUD + event publishing
lead_routing_service.pyRouting rules, assignment, SLA
health_scoring_service.pyScore computation, trend detection
intent_signal_service.pyIntent CRUD, signal-to-record matching
competitor_intel_service.pyCompetitor CRUD, change detection, battle cards
seo_audit_service.pySEO lifecycle, per-page scoring
reply_classification_service.pyClassify replies to funnel emails and act on them
sequence_conversion.pyOne-off conversion of sequences into funnels
expansion_playbook_service.pyPlaybook CRUD, enrollment, stage advance
handoff_service.pyHandoff lifecycle
abm_service.pyTarget lists + account engagement

Temporal activities#

All registered in dispatch.py:109-164:

ActivityRetryTimeoutWhat
identify_visitor_sessionSTANDARD2mMatch visitor to known account
process_visitor_eventsSTANDARD5mBatch event ingestion
verify_email_addressSTANDARD2mEmail validation
score_leadSTANDARD5mSingle lead score
batch_score_leadsSTANDARD30m, 5m heartbeatBulk
generate_weekly_gtm_reportSTANDARD10mWeekly digest
run_bulk_importSTANDARD30m, 5m heartbeatLead/account import
send_gtm_alertSTANDARD2mAlert delivery
route_new_leadSTANDARD2mApply routing rules
check_sla_breachesSTANDARD5mSLA monitoring (scheduled)
score_customer_healthSTANDARD5mSingle account
batch_score_customer_healthSTANDARD30m, 5m heartbeatBulk
detect_health_dropsSTANDARD5mAt-risk detection
evaluate_expansion_triggersSTANDARD5mPlaybook eligibility
advance_expansion_stepSTANDARD2mStep execution
collect_intent_signalsSTANDARD30m, 5m heartbeatPull third-party intent
match_intent_signals_to_recordsSTANDARD10mJoin signals
check_competitor_changesSTANDARD30m, 5m heartbeatCompetitor scrape
generate_battle_cardLLM10mLLM battle card
run_seo_auditSTANDARD15m, 30s heartbeatSEO audit
run_content_gap_analysisLLM30m, 5m heartbeatContent gap
recalculate_abm_engagementSTANDARD30m, 5m heartbeatABM engagement refresh
refresh_dynamic_abm_listsSTANDARD10mDynamic list eval

Periodic schedules in temporal/schedules.py drive the scheduled ones (compete-changes, intent collect, SLA checks, health drops, expansion triggers, ABM refresh, etc.).

Frontend#

19 pages under frontend/src/app/(app)/gtm/:

RoutePurpose
/gtmDashboard / overview
/gtm/abmAccount-Based Marketing
/gtm/visitorsVisitor tracking & identification
/gtm/providersData provider config
/gtm/healthCustomer health scoring
/gtm/content-gapSEO + content gap
/gtm/complianceConsent + suppression management
/gtm/alertsAlert configs + logs
/gtm/expansionExpansion playbooks
/gtm/intentIntent signal monitoring
/gtm/scoringLead scoring dashboard
/gtm/routingRouting rules + SLA dashboard
/gtm/seoSEO audit results
/gtm/importBulk import; can start a funnel for every imported contact
/gtm/handoffsCS-to-Sales handoffs
/gtm/competitorsCompetitor intel + battle cards
/gtm/analyticsMulti-view analytics

Common pitfalls#

  • Sequences are gone; convert before you forget. After upgrading to 0.58.0, nothing a sequence would have sent goes out until scripts/migrate_sequences_to_funnels.py has run. Contacts mid-sequence carry on only once it has.
  • Compliance check is per-call, not cached. Every outreach send queries /compliance/check. If you're dispatching tens of thousands of sends, the compliance service is on the hot path — keep its DB queries indexed (consent_records.email, suppression_list.email).
  • Health-score weights vs thresholds. Weights sum to 100 across the five sub-scores; thresholds partition 0-100 into health tiers. Changing weights doesn't auto-rescore — kick off batch_score_customer_health after weight changes.
  • Dynamic ABM lists drift. Criteria-based lists are re-evaluated only when refresh_dynamic_abm_lists runs. If the user just added a tag to an account, it won't appear in the list until the next refresh.
  • Reply classifier failure mode: when the LLM call fails or no provider is configured, the reply is recorded as question at 0.3 confidence, so no action runs. Check /replies/stats if triage looks off.
  • Visitor identification confidence: identify_visitor_session returns confidence ∈ [0, 1]. Acting on low-confidence matches creates duplicate accounts; set a workspace-wide threshold and respect it.
  • Routing rule priority is honored in order — first match wins. Don't add a catch-all rule at priority 1; it'll mask everything else.
  • run_seo_audit heartbeats every 30s because third-party SERP APIs throttle aggressively. Don't loop audits or fan them out without coordinating with the rate limiter.
  • Intent signals are 6-24h stale. collect_intent_signals is batched — don't gate real-time routing on intent freshness; gate on score + visitor data instead.
  • Handoff conversion creates a deal. Calling /handoffs/{id}/convert mutates the CRM — you can't undo it via the handoff endpoints. Use deal-level operations after the fact.
  • Webhook secret rotation is destructive. No overlap window — the next delivery signs with the new secret. Coordinate with consumers.