Most "CRM automation" I get asked to fix is a form that emails the office, plus a person who pastes it into HubSpot. The CRM then reports whatever the last person typed. Ads, the website, the phone line, and the invoice system each tell a different story about the same customer.
The job of an integration is to make one record the source of truth. Which CRM you use matters less than whether the lead source, stage, and revenue survive from first touch to paid job.
Everything below goes through each platform's official API under OAuth. The difference between a fragile sync and one staff actually trust is not the API. It's how you use it.
The write-path: four steps, in order
A working integration does four things every time an event comes in:
- Identify. Match on email, phone, or CRM id so you don't create a duplicate on every form submit.
- Create or update. Map to the fields staff already use, including source, campaign, and landing page.
- Advance. Move the stage when the real-world event happens (booked, quoted, invoiced), not whenever a zap fires.
- Write back. Send the money event to ads or analytics, so bidding optimizes for jobs, not thank-you pages.
If staff still open the CRM to re-type the form, the automation didn't ship. It created a second inbox.
The examples below use a thin crm adapter. In practice there's one implementation per vendor (HubSpot, Salesforce, Zoho), each wrapping the official API. The logic around it stays the same across all three.
Step 0: make retries harmless
Form vendors, telephony providers, and payment webhooks all retry. If your handler isn't idempotent, one lead becomes three contacts.
import hashlib
import json
import redis
r = redis.Redis()
def idempotency_key(payload: dict) -> str:
# Hash the fields that identify this submission, not the whole body.
# Some vendors add a delivery timestamp on retry, which would change the hash.
identity = {k: payload.get(k) for k in ("form_id", "email", "phone", "submitted_at")}
raw = json.dumps(identity, sort_keys=True)
return "lead:" + hashlib.sha256(raw.encode()).hexdigest()
def handle_lead(payload: dict, crm) -> str:
key = idempotency_key(payload)
# SET NX: only the first delivery claims the key.
# 24 hours covers the retry window of most vendors.
if not r.set(key, "processing", nx=True, ex=86400):
return r.get(key).decode() # already done, or in flight
try:
contact_id = upsert_contact(payload, crm)
except Exception:
r.delete(key) # release so the next retry can try again
raise
r.set(key, contact_id, ex=86400)
return contact_id
Step 1 and 2: identify, then upsert
Normalize before you match. John@Example.com and john@example.com are the same person, and so are three different phone formats.
import phonenumbers
def normalize_email(email: str | None) -> str | None:
return email.strip().lower() if email else None
def normalize_phone(phone: str | None, region: str = "US") -> str | None:
if not phone:
return None
try:
parsed = phonenumbers.parse(phone, region)
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except phonenumbers.NumberParseException:
return None
def upsert_contact(payload: dict, crm) -> str:
email = normalize_email(payload.get("email"))
phone = normalize_phone(payload.get("phone"))
existing = crm.find_contact(email=email) or crm.find_contact(phone=phone)
incoming = map_fields(payload) # vendor field names -> your CRM properties
if existing is None:
return crm.create_contact(incoming)
update = build_update(existing, incoming)
if update:
crm.update_contact(existing["id"], update)
return existing["id"]
Protect the source fields
This is the bug that quietly breaks reporting. A lead clicks a Google ad, books through the voice agent, and then a receptionist edits the record and sets the source to "phone." Last write wins, and the ad click is gone.
Phone is a channel. It isn't a campaign. Treat attribution fields as write-once:
WRITE_ONCE = {"lead_source", "utm_source", "utm_medium", "utm_campaign", "gclid", "landing_page"}
def build_update(existing: dict, incoming: dict) -> dict:
update = {}
for field, value in incoming.items():
if value in (None, ""):
continue # never blank out a field with an empty value
if field in WRITE_ONCE and existing.get(field):
continue # first touch wins
if existing.get(field) == value:
continue # skip no-op writes, they burn rate limit
update[field] = value
return update
Most CRMs also let you make these properties read-only for regular users. Do both. The code protects against integrations, and the permissions protect against people.
Step 3: only move stages forward
Stage changes should follow real events, and they should never go backwards because events arrived out of order. A "booked" webhook that arrives late must not overwrite "invoiced."
STAGES = ["new", "booked", "quoted", "invoiced", "paid"]
def advance_stage(contact: dict, event_stage: str, crm) -> None:
current = contact.get("stage", "new")
if STAGES.index(event_stage) > STAGES.index(current):
crm.update_contact(contact["id"], {"stage": event_stage})
The failure modes that wake you up
These are the ones I see most often in production:
-
Token refresh. Unattended integrations die on Monday because a token expired on Friday. Store
expires_at, refresh before it lapses, and alert when a refresh fails, not when the next sync does. - Idempotency. Covered above. If you skip it, every retry becomes a duplicate.
- Partial updates. A multi-step write fails halfway, and now a paid invoice sits on "new lead." Put each write in a queue with retries so a failed step gets retried instead of forgotten. An outbox table in Postgres works well for this.
- Rate limits. Bulk imports and a sudden burst of form submissions need different designs. Queue writes and batch where the API allows it, rather than hitting the API once per event.
- Human edits. Covered by the write-once fields above.
For a high-volume pipeline you also have to report on, don't treat the CRM's API as your database. Keep your own copy in Postgres, use Redis for idempotency and locks, and treat the CRM as the place where staff see the record.
Where voice agents fit
A voice agent that books after hours has to create or update the same lead a web form would, including source and campaign. Otherwise after-hours bookings become a pile the morning staff re-enters by hand, and the attribution is lost in the process.
In my experience this CRM write-back, not the voice itself, is what takes most of the effort in a voice agent build. Matching the right contact, catching duplicates, advancing the right pipeline stage, and handling failed bookings is where the time goes.
Stay on your CRM or build one?
If sales already lives in HubSpot, Salesforce, or Zoho, the work is usually OAuth, object mapping, and workflow triggers, not a rewrite. Custom objects, associations, and permission models are where generic zaps go quiet, so that's where an owned integration earns its keep.
A custom CRM makes sense when the workflow is the product. One example is a trades business that needs lead scoring, a job board, quotes, invoices, and ads attribution in one tenanted system. Stacking three SaaS tools to get there often costs more over two years than building it.
A sequence you can start with
- List the money events: form, call, chat, quote sent, invoice paid.
- Pick one system of record for each object. One, not three.
- Map every field, including source. If source is optional, it will end up empty.
- Use official OAuth for both the CRM and ads. Unofficial scrapers and connectors break during verification and can get accounts banned.
- Turn off the duplicate path (the "also email the office" zap) once you trust the write-path.
- Review weekly: ad spend vs booked jobs vs paid invoices.
I originally wrote this for the KeenCraft blog. If you want the attribution side in more depth, how to carry gclid and UTMs from the ad click to a booked job, that's covered in attribution tracking that ties ad spend to real bookings.
What's the worst duplicate-contact or overwritten-source bug you've run into? I'd like to hear how other people handled it.
Top comments (0)