Back to CRM Data Hygiene

Developer Handover Pack

Everything needed to build the automation from scratch: current state, full agent specs, data flow, and the build stack.

4 pagesPDF · Technical
FS-DOC-04Technical

Developer Handover Pack

CRM Data Hygiene Automation

[YourCompany.com] · Sales Department · Prepared by FullSpec · [Today's Date]

This document gives the FullSpec build team everything needed to construct, wire, and deploy the CRM Data Hygiene automation from first credential to live weekly run. It covers the current-state step map with bottleneck flags, full specifications for each of the three agents, the end-to-end data flow with field-level detail, and the recommended build stack. All build decisions, tooling choices, and exception-handling behaviours described here are based on the confirmed process mapping and the Standard build option at $4,800 one-off cost.

01Process snapshot

Step
Name
Description
01
Export CRM Records to Spreadsheet
Sales Ops manually exports all contacts and open deals from HubSpot into Google Sheets, filtering by date range or owner. Run every week or on complaint. Time cost: 20 min.
02
Scan for Duplicate Contacts
BOTTLENECK. Ops person uses spreadsheet formulas and manual scrolling to surface contacts sharing email, phone, or name-plus-company values. Duplicates are highlighted manually. Time cost: 45 min.
03
Merge or Flag Duplicate Records
BOTTLENECK. Each duplicate pair is reviewed individually. The ops person decides the master record, copies across any unique data from the secondary, then merges or deletes inside the CRM. Time cost: 35 min.
04
Identify Missing or Incomplete Fields
Spreadsheet is filtered for blank job title, phone, company size, or lifecycle stage. Contacts missing critical fields are flagged for enrichment. Time cost: 25 min.
05
Enrich Contact Data Manually
BOTTLENECK. Ops person or rep searches LinkedIn or company websites to fill missing fields, then updates each contact record one at a time inside the CRM. Time cost: 40 min.
06
Review Stale Open Deals
Deals with no activity in 30 or more days are identified by scanning the pipeline view. Ops person emails or messages the deal owner to confirm status. Time cost: 20 min.
07
Update or Close Stale Deals
Based on rep responses, ops manually moves deals to closed-lost, updates close dates, or adds follow-up tasks. Requires multiple back-and-forth messages. Time cost: 20 min.
08
Remove or Suppress Unsubscribed Contacts
Email unsubscribes and hard bounces from the marketing tool are cross-checked against the CRM to suppress affected contacts from future outreach and sequences. Time cost: 15 min.
09
Log Changes and Produce Summary Report
Ops updates a running change log and sends a brief summary to the sales manager covering duplicates merged, records enriched, and deals closed. Time cost: 20 min.
Time cost summary: Total manual time per cycle is 240 minutes (4 hours). At 5 hours lost per week across the full run cadence, the annualised cost reaches approximately $14,300 at a $55/hr blended rate. The automation replaces steps 02, 03, 04, 05, 06, 08, and 09 entirely. Step 07 (rep confirms or closes stale deals) is reduced to exception-only action via the Slack digest. Step 01 (manual export) is eliminated because the automation pulls records directly from the HubSpot API.
Developer Handover PackPage 1 of 4
FS-DOC-04Technical

02Agent specifications

Deduplication Agent

Scans all CRM contacts on each run using email address (exact match), phone number (normalised E.164 format), and fuzzy name-plus-company matching using Jaro-Winkler or equivalent string similarity scoring. Each candidate pair is assigned a confidence score from 0 to 100. Pairs scoring at or above the agreed threshold are merged automatically into the CRM, keeping the more complete record and appending any unique field values from the secondary. Pairs scoring below the threshold are queued as borderline matches and passed downstream to the Slack digest for human review. No destructive action is taken on borderline pairs without explicit human approval.

Trigger
Monday 07:00 weekly schedule (cron), OR immediately on new contact creation webhook from HubSpot
Tools
HubSpot (API read + merge endpoint), Salesforce (if dual-CRM configured: connected app OAuth)
Replaces steps
Steps 02 and 03 (duplicate scanning and manual merge)
Estimated build
12 hours | Moderate complexity
// Input
contacts[]: { id, email, phone_e164, firstname, lastname, company, hs_object_id }
deals[]:    { id, dealname, pipeline, dealstage, hs_lastmodifieddate, hubspot_owner_id }
config:     { confidence_threshold: 80, fuzzy_algo: 'jaro_winkler', stale_deal_days: 30 }

// Processing
pair_scores[] = compare_all_pairs(contacts[], ['email','phone_e164','name+company'])
high_confidence = pair_scores.filter(score >= confidence_threshold)
borderline      = pair_scores.filter(score >= 60 && score < confidence_threshold)

// Output
merged_records[]:   { primary_id, secondary_id, merged_fields[], merge_timestamp }
flagged_for_review[]: { pair_id, contact_a_id, contact_b_id, score, reason_label }
agent_summary:      { run_id, records_scanned, pairs_evaluated, auto_merged, flagged }
  • HubSpot merge endpoint requires a Professional or Enterprise tier subscription. Confirm the account tier before build. The endpoint is POST /crm/v3/objects/contacts/merge and requires both contact IDs and designation of the primary record ID.
  • Salesforce write-back requires a Connected App with OAuth 2.0 (client credentials flow). Read-only access is insufficient. The connected app must have 'Manage Contacts' and 'Merge Records' permissions. Confirm credentials are provisioned before the agent is wired to production.
  • Phone number normalisation must strip all non-numeric characters and prepend the country code before comparison to avoid false negatives on formatting variants.
  • The confidence threshold default is 80 during QA. This value must be tuned against a representative sample of real records (minimum 500 pairs) before go-live. Threshold configuration must be stored as an environment variable, not hardcoded.
  • Deduplication runs must be idempotent: if a pair was already merged in a prior run, it must not be re-evaluated. Maintain a processed_pairs log (run_id, pair_hash) to prevent double-processing.
  • Auto-merge must never fire on contacts tagged with the property do_not_merge: true. This property must be created in HubSpot before build begins.
Enrichment Agent

Receives the cleaned contact set produced after deduplication and identifies records with one or more empty priority fields: job title, company size (number of employees), and industry. Each qualifying contact is submitted to the Clearbit Enrichment API using the contact email address as the lookup key. Returned values are written back into the CRM only where the target field is currently blank, preserving any data the team has entered manually. Records where Clearbit returns no result, or where the confidence flag is below acceptable quality, are collected into an enrichment failure list and passed to the exceptions digest.

Trigger
Fires automatically after the Deduplication Agent emits its agent_summary output in the same run context
Tools
Clearbit Enrichment API (person and company endpoints), HubSpot (PATCH contact properties), Salesforce (updateRecord if dual-CRM)
Replaces steps
Steps 04 and 05 (identifying missing fields and manual enrichment)
Estimated build
8 hours | Moderate complexity
// Input
cleaned_contacts[]: { id, email, jobtitle, company, numberofemployees, industry }
config:             { priority_fields: ['jobtitle','numberofemployees','industry'],
                      overwrite_existing: false,
                      clearbit_monthly_lookup_cap: 2000 }

// Clearbit request (per contact)
GET https://person.clearbit.com/v2/combined/find?email={contact.email}
Headers: Authorization: Bearer {CLEARBIT_API_KEY}

// Field mapping: Clearbit response -> HubSpot property
person.title          -> jobtitle
company.metrics.employees -> numberofemployees
company.category.industry -> industry

// Output
enriched_records[]:  { contact_id, fields_written[], enrichment_source: 'clearbit' }
enrichment_failures[]: { contact_id, email, failure_reason }
enrichment_summary:  { run_id, submitted, enriched, skipped_existing, failed }
  • Clearbit monthly plan at $99/month includes a lookup cap. Set a hard cap of 2,000 lookups per month as an environment variable and halt enrichment with an alert if the cap is reached mid-run.
  • Enrichment must be prioritised by pipeline stage: contacts associated with open deals in active stages (Appointment Scheduled, Contract Sent) are enriched first. Lower-priority contacts are enriched in subsequent runs if cap headroom remains.
  • The overwrite_existing flag must be false at all times. If the CRM already contains a value for a target field, the Clearbit value is discarded regardless of quality.
  • Confirm with the ops lead before build which fields are off-limits for enrichment (custom manual fields). These must be excluded from the field mapping list and stored in a protected_fields config array.
  • Clearbit rate limit is 600 requests per minute on the Enrichment endpoint. Implement exponential backoff with a maximum of 3 retries on 429 responses. Log each retry with the contact ID and timestamp.
  • Clearbit responses with confidence below 0.7 (as indicated by the fuzzy flag in the response body) must be treated as failures and not written to the CRM.
Deal Hygiene Agent

Reviews all open deals and active contacts in the CRM after enrichment completes. Deals with no activity recorded beyond the configured stale threshold (default: 30 days) are flagged for rep confirmation. Contacts marked as hard-bounced in the email system or unsubscribed from marketing sequences are identified and tagged for suppression, preventing them from appearing in future outreach or automated sequences. All flagged items are collected into a structured exceptions digest, which is posted as a single Slack message to the designated channel. A summary row is appended to the Google Sheets audit log for audit and reporting purposes.

Trigger
Fires automatically after Enrichment Agent emits enrichment_summary in the same run context
Tools
HubSpot (deal and contact read, property write for suppression tag), Salesforce (if applicable), Slack (chat.postMessage to #sales-ops), Google Sheets (Sheets API row append)
Replaces steps
Steps 06, 08, and 09 (stale deal review, suppression, and audit logging)
Estimated build
8 hours | Moderate complexity
// Input
open_deals[]:     { deal_id, dealname, hubspot_owner_id, hs_lastmodifieddate,
                    pipeline, dealstage, associated_contact_ids[] }
active_contacts[]: { contact_id, email, hs_email_hard_bounce: bool,
                     unsubscribed: bool, opted_out_of_all_email: bool }
config:           { stale_deal_days: 30, slack_channel: '#sales-ops',
                    sheets_doc_id: '{SHEETS_DOC_ID}', audit_tab: 'HygieneLog' }

// Processing
stale_deals     = open_deals.filter(days_since_modified >= stale_deal_days)
suppress_list   = active_contacts.filter(hard_bounce == true || unsubscribed == true)
suppress_list.forEach -> PATCH contact { hs_email_optout: true, crm_suppressed: true }

// Output
slack_digest:   { stale_deals[], borderline_duplicates[], enrichment_failures[],
                  suppressed_contacts[], run_timestamp, run_id }
sheets_row:     { run_date, records_scanned, auto_merged, enriched,
                  stale_flagged, suppressed, enrichment_failures, run_id }

// On approval (human action via Slack)
Rep selects 'Close Deal' or 'Keep Open' in Slack -> webhook fires ->
  PATCH /crm/v3/objects/deals/{deal_id} { dealstage: 'closedlost' | no change }
  • Slack posting requires a Slack App installed to the workspace with the chat:write scope and the bot added to the #sales-ops channel. Confirm the Slack App is created and the bot token (xoxb-) is stored in the credential store before build.
  • The stale deal threshold in days must be stored as a configurable environment variable (STALE_DEAL_DAYS, default 30). Do not hardcode. Confirm the threshold value with the ops lead before go-live.
  • Suppression write-back sets the HubSpot property hs_email_optout and a custom property crm_suppressed: true. Confirm both properties exist in the HubSpot property schema before the agent is built.
  • Google Sheets append uses the Sheets API v4 spreadsheets.values.append method. The service account or OAuth token used must have Editor access to the target document. Store the Sheets document ID as SHEETS_DOC_ID in the credential store.
  • The Slack digest must be a single message per run, not one message per flagged item. Use Slack Block Kit layout with sections for each category (stale deals, borderline duplicates, enrichment failures, suppressed contacts) to keep the channel readable.
  • If the Slack post fails, the fallback is to append the full digest content to the Google Sheets log with a flag column set to slack_failed, and trigger an error alert to the configured error notification email.
Developer Handover PackPage 2 of 4
FS-DOC-04Technical

03End-to-end data flow

Full trigger-to-output data flow with agent handoffs and field-level detail
// ============================================================
// TRIGGER
// ============================================================
EVENT: cron('0 7 * * MON') | webhook: HubSpot contact.creation
  -> run_id = uuid()
  -> run_context = { run_id, triggered_by: 'schedule'|'webhook', timestamp }

// ============================================================
// STEP 1: Pull records from HubSpot API
// ============================================================
GET /crm/v3/objects/contacts?properties=email,phone,firstname,lastname,
    company,jobtitle,numberofemployees,industry,
    hs_email_hard_bounce,opted_out_of_all_email,do_not_merge
    &limit=100 (paginated, after cursor)
GET /crm/v3/objects/deals?properties=dealname,pipeline,dealstage,
    hs_lastmodifieddate,hubspot_owner_id,associations
    &limit=100 (paginated)
  -> contacts[]: { id, email, phone_e164, firstname, lastname,
                   company, jobtitle, numberofemployees, industry,
                   hs_email_hard_bounce, opted_out_of_all_email, do_not_merge }
  -> deals[]:    { id, dealname, pipeline, dealstage,
                   hs_lastmodifieddate, hubspot_owner_id,
                   associated_contact_ids[] }

// ============================================================
// AGENT HANDOFF 1: contacts[] + deals[] -> Deduplication Agent
// ============================================================
Deduplication Agent receives: contacts[], config.confidence_threshold
  // Phase A: exact-match pass
  email_groups    = group_by(contacts[], 'email')
  phone_groups    = group_by(contacts[], 'phone_e164')
  // Phase B: fuzzy name+company pass (Jaro-Winkler)
  fuzzy_pairs     = jaro_winkler_compare(firstname+lastname+company)
  all_pairs       = dedupe(email_groups + phone_groups + fuzzy_pairs)
  pair_scores[]   = score_pairs(all_pairs)   // 0-100
  high_confidence = pair_scores.filter(score >= 80)
  borderline      = pair_scores.filter(score >= 60 && score < 80)
  // Write-back: auto-merge
  high_confidence.forEach(pair):
    POST /crm/v3/objects/contacts/merge
      { primaryObjectId: pair.primary_id,
        objectIdToMerge: pair.secondary_id }
  -> merged_records[]:   { primary_id, secondary_id, merged_fields[], merge_timestamp }
  -> flagged_for_review[]: { pair_id, contact_a_id, contact_b_id, score, reason_label }
  -> agent_summary_dedup: { run_id, records_scanned, pairs_evaluated,
                            auto_merged, flagged }

// ============================================================
// AGENT HANDOFF 2: cleaned contacts[] -> Enrichment Agent
// ============================================================
Enrichment Agent receives: post-merge contacts[], config.priority_fields,
                           config.overwrite_existing: false
  enrich_queue = contacts[].filter(
    jobtitle == null || numberofemployees == null || industry == null
  ).sortBy(pipeline_stage_priority DESC)
  enrich_queue.forEach(contact):
    // Clearbit lookup
    GET https://person.clearbit.com/v2/combined/find?email={contact.email}
      Headers: Authorization: Bearer {CLEARBIT_API_KEY}
    response_fields = {
      person.title          -> jobtitle,
      company.metrics.employees -> numberofemployees,
      company.category.industry -> industry
    }
    // Conditional write: only if target field is currently null
    if (field == null && clearbit_confidence >= 0.7):
      PATCH /crm/v3/objects/contacts/{contact.id}
        { properties: { [field]: clearbit_value } }
    else: push contact.id -> enrichment_failures[]
  -> enriched_records[]:  { contact_id, fields_written[], enrichment_source }
  -> enrichment_failures[]: { contact_id, email, failure_reason }
  -> agent_summary_enrich: { run_id, submitted, enriched, skipped_existing, failed }

// ============================================================
// AGENT HANDOFF 3: open_deals[] + contacts[] -> Deal Hygiene Agent
// ============================================================
Deal Hygiene Agent receives: open_deals[], active_contacts[],
                             agent_summary_dedup, agent_summary_enrich
  // Stale deal detection
  stale_deals = open_deals.filter(
    days_since(hs_lastmodifieddate) >= STALE_DEAL_DAYS
  )
  // Suppression identification
  suppress_list = active_contacts.filter(
    hs_email_hard_bounce == true || opted_out_of_all_email == true
  )
  suppress_list.forEach(contact):
    PATCH /crm/v3/objects/contacts/{contact.id}
      { properties: { hs_email_optout: true, crm_suppressed: true } }
  // Compile full digest
  digest = {
    stale_deals[]:         { deal_id, dealname, owner_id, days_inactive }
    borderline_duplicates[]: flagged_for_review[] from Dedup Agent
    enrichment_failures[]:   from Enrichment Agent
    suppressed_contacts[]: suppress_list[]
    run_id, run_timestamp
  }

// ============================================================
// OUTPUT A: Post Slack digest
// ============================================================
  POST https://slack.com/api/chat.postMessage
    { channel: '#sales-ops',
      blocks: BlockKit(digest) }
  on failure -> append digest to sheets_row with flag: 'slack_failed'
             -> trigger error alert to support@gofullspec.com

// ============================================================
// OUTPUT B: Append Google Sheets audit row
// ============================================================
  POST https://sheets.googleapis.com/v4/spreadsheets/{SHEETS_DOC_ID}
       /values/{audit_tab}!A:L:append
    { values: [[run_date, records_scanned, auto_merged, enriched,
                stale_flagged, suppressed, enrichment_failures,
                borderline_flagged, run_id, triggered_by,
                slack_status, notes]] }

// ============================================================
// HUMAN STEP (exception only): Rep confirms or closes stale deal
// ============================================================
  Slack interactive button -> webhook POST to automation platform
    payload: { deal_id, action: 'close'|'keep', actor_slack_id }
  if action == 'close':
    PATCH /crm/v3/objects/deals/{deal_id}
      { properties: { dealstage: 'closedlost',
                      closed_lost_reason: 'Stale - confirmed by rep' } }
  if action == 'keep':
    PATCH /crm/v3/objects/deals/{deal_id}
      { properties: { hs_lastmodifieddate: now() } }  // resets stale clock
  -> append outcome to sheets audit row for this run_id
Developer Handover PackPage 3 of 4
FS-DOC-04Technical

04Recommended build stack

Orchestration layer
A workflow automation platform configured with one workflow per agent (three workflows total: Deduplication, Enrichment, Deal Hygiene). Each workflow is triggered either by a cron schedule (Monday 07:00) or by a webhook event (HubSpot contact.creation). Workflows share a single credential store so that API keys and tokens are defined once and referenced by name across all three workflows. No credentials are hardcoded in workflow steps.
Webhook configuration
HubSpot sends a contact.creation webhook to the automation platform's inbound webhook URL (HTTPS, secret-verified). The platform validates the HubSpot webhook secret header (X-HubSpot-Signature-v3) before processing. Slack interactive button callbacks post to a separate inbound webhook endpoint on the platform, verified by the Slack signing secret. Both webhook URLs must be registered in the respective tool dashboards before QA begins.
Templating approach
The Slack digest message is built using Slack Block Kit JSON, templated inside the automation platform using a structured JSON-building step. The Block Kit template is stored as a reusable component with dynamic value injection for deal counts, contact IDs, and action buttons. The Google Sheets audit row uses a fixed column schema (A through L) defined at build time. Any schema change requires a version bump and migration of existing rows.
Error logging
All agent-level errors (Clearbit 429, HubSpot merge failure, Slack post failure, Sheets append failure) are written to a Supabase table named crm_hygiene_error_log with columns: error_id (uuid), run_id, agent_name, step_name, error_code, error_message, contact_or_deal_id, timestamp, resolved (bool). A Supabase database webhook or scheduled function checks for unresolved rows older than 1 hour and sends an alert to support@gofullspec.com. The Supabase project is provisioned in the same environment as the automation platform credentials.
Testing approach
All three agents are built and tested against a HubSpot sandbox account and a Clearbit test API key before any production credentials are connected. A representative export of at least 500 real contact records (anonymised if required) is used to tune the duplicate confidence threshold during QA. The parallel run in week 4 runs the automation and the manual process side by side, with outputs compared before the manual routine is retired. Production write-back is enabled only after the ops lead signs off on the parallel run results.
Estimated total build time
Deduplication Agent: 12 hours. Enrichment Agent: 8 hours. Deal Hygiene Agent: 8 hours. End-to-end integration testing, credential wiring, Slack Block Kit build, Sheets schema setup, error log configuration, and parallel run support: 4 hours. Total: 32 hours across a 4-week delivery schedule.
Before any agent is connected to the production CRM, the following must be confirmed: (1) HubSpot account tier supports the merge API endpoint; (2) the do_not_merge and crm_suppressed contact properties exist in the HubSpot schema; (3) Clearbit API key is provisioned and the monthly lookup cap is agreed; (4) the Slack App bot token is stored in the credential store and the bot is added to #sales-ops; (5) the Google Sheets audit document ID is confirmed and the service account has Editor access; (6) the stale deal threshold in days is agreed with the ops lead and stored as STALE_DEAL_DAYS. Raise any blockers to support@gofullspec.com before the build start date.
Developer Handover PackPage 4 of 4

More documents for this process

Every document generated for CRM Data Hygiene.

Launch Plan
Operations · Owner
View
ROI and Business Case
Finance · Owner
View
Process Runbook / SOP
Operations · Owner
View
Integration and API Spec
Technical · Developer
View
Test and QA Plan
Quality · Developer
View