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-router | Purpose |
|---|---|---|
| 1 | providers | Configure data/enrichment providers (Apollo, ZoomInfo, etc.) |
| 2 | dashboard | KPI overview, funnel, recent visitors, pipeline metrics |
| 3 | icp | Ideal Customer Profile templates |
| 4 | visitors | Visitor session tracking + identification |
| 5 | compliance | Consent, suppression, audit |
| 6 | scoring | Lead score CRUD + rescoring |
| 7 | dedup | Duplicate detection + merge |
| 8 | imports, replies | Contact import and reply classification; outreach runs as funnels |
| 9 | analytics | Pipeline, channels, attribution, trends |
| 10 | alerts | Event-driven alert configs + logs |
| 11 | routing | Lead routing rules + SLA tracking |
| 12 | health | Customer-health scoring |
| 13 | expansion | Upsell/cross-sell playbooks |
| 14 | handoffs | CS → Sales handoffs |
| 15 | intent | Buying-signal monitoring |
| 16 | competitors | Competitive intel + battle cards |
| 17 | seo | SEO audits + content-gap analysis |
| 18 | abm | Target lists, tiering, stage progression |
| 19 | webhooks | Subscribe 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:
| Category | Action |
|---|---|
interested, not_interested, wrong_person, referral | The contact leaves every funnel they're in (exit reason reply:<category>) |
unsubscribe | Unsubscribed from all marketing mail, and out of every funnel |
ooo, question | Recorded 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#
| Model | Highlights |
|---|---|
IntentSignal | signal_type, intent_strength, confidence_score, signal_data JSONB, is_dismissed |
IntentSignalConfig | monitored_domains, job_title_keywords, tech_keywords, competitor_names, signal_weights JSONB |
gtm_alerts.py#
| Model | Highlights |
|---|---|
GTMAlertConfig | event_type, conditions JSONB, channel_type, channel_config, message_template, is_active |
GTMAlertLog | delivery_status, error_message, sent_at |
gtm_routing.py#
| Model | Highlights |
|---|---|
GTMRoutingRule | priority, is_active, conditions JSONB, strategy, assignee_pool, sla_first_response_minutes, sla_follow_up_minutes |
GTMLeadAssignment | record_id, assignee_id, assigned_at, first_response_at, sla_breached, sla_breach_at |
gtm_health.py#
| Model | Highlights |
|---|---|
GTMHealthScore | total_score, five sub-scores (0-100), health_status, trend, scoring_factors JSONB, score_history JSONB |
GTMHealthConfig | weights (defaults engagement 25, usage 30, support 20, NPS 15, payment 10), thresholds |
gtm_abm.py#
| Model | Highlights |
|---|---|
ABMTargetList | name, criteria JSONB (industries, employee/revenue ranges), is_dynamic, is_active, account_count |
ABMAccount | tier, stage, engagement_score, contact counts, activity metrics (emails, meetings, deals) |
gtm_handoff.py#
| Model | Highlights |
|---|---|
GTMHandoff | handoff_type, title, context, estimated_value, products JSONB, signals JSONB, status, sla_accept_minutes, sla_breached |
Plus#
gtm_webhook.py— webhook configs + deliveriesgtm_seo.py— audit results + per-page scoresgtm_competitor.py— competitor records + tracked changesgtm_compliance.py— consent records + suppression listsgtm_expansion.py— playbook templates + enrollmentsgtm_content.py— content-gap analysis records
Services (15 files)#
Under backend/src/aexy/services/:
| Service | Domain |
|---|---|
gtm_service.py | Provider config, dashboard aggregation, ICP CRUD |
gtm_analytics_service.py | Pipeline/channel/attribution/funnel comparison/trends |
gtm_alert_service.py | Alert configs, event matching, dispatch |
gtm_compliance_service.py | Consent, suppression |
gtm_webhook_service.py | Webhook CRUD + event publishing |
lead_routing_service.py | Routing rules, assignment, SLA |
health_scoring_service.py | Score computation, trend detection |
intent_signal_service.py | Intent CRUD, signal-to-record matching |
competitor_intel_service.py | Competitor CRUD, change detection, battle cards |
seo_audit_service.py | SEO lifecycle, per-page scoring |
reply_classification_service.py | Classify replies to funnel emails and act on them |
sequence_conversion.py | One-off conversion of sequences into funnels |
expansion_playbook_service.py | Playbook CRUD, enrollment, stage advance |
handoff_service.py | Handoff lifecycle |
abm_service.py | Target lists + account engagement |
Temporal activities#
All registered in dispatch.py:109-164:
| Activity | Retry | Timeout | What |
|---|---|---|---|
identify_visitor_session | STANDARD | 2m | Match visitor to known account |
process_visitor_events | STANDARD | 5m | Batch event ingestion |
verify_email_address | STANDARD | 2m | Email validation |
score_lead | STANDARD | 5m | Single lead score |
batch_score_leads | STANDARD | 30m, 5m heartbeat | Bulk |
generate_weekly_gtm_report | STANDARD | 10m | Weekly digest |
run_bulk_import | STANDARD | 30m, 5m heartbeat | Lead/account import |
send_gtm_alert | STANDARD | 2m | Alert delivery |
route_new_lead | STANDARD | 2m | Apply routing rules |
check_sla_breaches | STANDARD | 5m | SLA monitoring (scheduled) |
score_customer_health | STANDARD | 5m | Single account |
batch_score_customer_health | STANDARD | 30m, 5m heartbeat | Bulk |
detect_health_drops | STANDARD | 5m | At-risk detection |
evaluate_expansion_triggers | STANDARD | 5m | Playbook eligibility |
advance_expansion_step | STANDARD | 2m | Step execution |
collect_intent_signals | STANDARD | 30m, 5m heartbeat | Pull third-party intent |
match_intent_signals_to_records | STANDARD | 10m | Join signals |
check_competitor_changes | STANDARD | 30m, 5m heartbeat | Competitor scrape |
generate_battle_card | LLM | 10m | LLM battle card |
run_seo_audit | STANDARD | 15m, 30s heartbeat | SEO audit |
run_content_gap_analysis | LLM | 30m, 5m heartbeat | Content gap |
recalculate_abm_engagement | STANDARD | 30m, 5m heartbeat | ABM engagement refresh |
refresh_dynamic_abm_lists | STANDARD | 10m | Dynamic 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/:
| Route | Purpose |
|---|---|
/gtm | Dashboard / overview |
/gtm/abm | Account-Based Marketing |
/gtm/visitors | Visitor tracking & identification |
/gtm/providers | Data provider config |
/gtm/health | Customer health scoring |
/gtm/content-gap | SEO + content gap |
/gtm/compliance | Consent + suppression management |
/gtm/alerts | Alert configs + logs |
/gtm/expansion | Expansion playbooks |
/gtm/intent | Intent signal monitoring |
/gtm/scoring | Lead scoring dashboard |
/gtm/routing | Routing rules + SLA dashboard |
/gtm/seo | SEO audit results |
/gtm/import | Bulk import; can start a funnel for every imported contact |
/gtm/handoffs | CS-to-Sales handoffs |
/gtm/competitors | Competitor intel + battle cards |
/gtm/analytics | Multi-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.pyhas 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_healthafter weight changes. - Dynamic ABM lists drift. Criteria-based lists are re-evaluated only when
refresh_dynamic_abm_listsruns. 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
questionat 0.3 confidence, so no action runs. Check/replies/statsif triage looks off. - Visitor identification confidence:
identify_visitor_sessionreturnsconfidence∈ [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_auditheartbeats 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_signalsis batched — don't gate real-time routing on intent freshness; gate on score + visitor data instead. - Handoff conversion creates a deal. Calling
/handoffs/{id}/convertmutates 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.