Developer Handover Pack
Everything needed to build the automation from scratch: current state, full agent specs, data flow, and the build stack.
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
02Agent specifications
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.
// 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.
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.
// 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.
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.
// 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.
03End-to-end data flow
// ============================================================
// 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_id04Recommended build stack
More documents for this process
Every document generated for CRM Data Hygiene.