Fetching latest headlines…

Dev

Writing Back to HubSpot, Salesforce, and Zoho Without Creating Duplicate Contacts

Dev.toUnited States · NORTH AMERICA

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 li...

0 views0 likes0 comments

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:

  1. Identify. Match on email, phone, or CRM id so you don't create a duplicate on every form submit.
  2. Create or update. Map to the fields staff already use, including source, campaign, and landing page.
  3. Advance. Move the stage when the real-world event happens (booked, quoted, invoiced), not whenever a zap fires.
  4. 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. [email protected] and [email protected] 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

  1. List the money events: form, call, chat, quote sent, invoice paid.
  2. Pick one system of record for each object. One, not three.
  3. Map every field, including source. If source is optional, it will end up empty.
  4. Use official OAuth for both the CRM and ads. Unofficial scrapers and connectors break during verification and can get accounts banned.
  5. Turn off the duplicate path (the "also email the office" zap) once you trust the write-path.
  6. 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.

Comments (0)

Sign in to join the discussion

Be the first to comment!