Two-way sync between a WhatsApp platform and a CRM sounds like two one-way syncs. Push contact changes to the CRM; pull CRM changes back. Then you switch both on and discover the loop: your export updates HubSpot, HubSpot's webhook tells you the contact changed, your import writes it locally, your local write fires the export hook, and the same contact bounces between the two systems until a rate limit stops it. Fix that and the next problems arrive: duplicate contacts created by your own webhook, phone numbers turned into the wrong country, two people editing the same contact on different sides, and a CRM clean-up that deletes WhatsApp chat history.
This post builds a two-way contact sync that handles each of those, in TypeScript, with tests that wire two fake systems together so every write really does fire the other side's hook. It's based on WAPGROWAI's HubSpot integration: the parts we got right, and the parts the tests below would have caught.
The snippets are small, self-contained versions of the patterns, and each was run against the cases shown.
Why the loop happens, and why "ignore my own changes" isn't enough
Every write in a two-way sync is also an event. Locally, a model hook fires when a contact changes. In the CRM, a webhook fires. Neither knows who made the change.
HubSpot's webhook payloads do carry changeSource and sourceId, described as "the user or source of the triggered webhook event". But the webhook documentation doesn't define a value that reliably says "this change was made by your app", so building on it means relying on undocumented behaviour. We need a guard that works from the data itself.
The shared shape and the fingerprint
import { createHash } from "node:crypto"; // The fields both systems share, in one canonical shape. export type Canonical = { name: string; phone: string }; // phone in E.164 export type CrmProps = { firstname?: string | null; lastname?: string | null; phone?: string | null }; // CRMs store phones however people typed them. A bare national number must get // the default country code, not a "+" glued to the front: "98200 11223" is an // Indian mobile, not +98 (Iran). In production, use libphonenumber with a region. export const toE164 = (raw: string | null | undefined, defaultCountryCode = "91"): string | null => { const text = (raw ?? "").trim(); const digits = text.replace(/\D/g, ""); if (digits.length < 6) return null; if (text.startsWith("+")) return `+${digits}`; if (text.startsWith("00")) return `+${digits.slice(2)}`; if (digits.length === 11 && digits.startsWith("0")) return `+${defaultCountryCode}${digits.slice(1)}`; if (digits.length === 10) return `+${defaultCountryCode}${digits}`; return `+${digits}`; }; export const fromCrm = (p: CrmProps, defaultCountryCode?: string): Canonical | null => { const phone = toE164(p.phone, defaultCountryCode); if (!phone) return null; const name = [p.firstname, p.lastname].map((s) => (s ?? "").trim()).filter(Boolean).join(" "); return { name, phone }; }; export const toCrm = (c: Canonical): CrmProps => { const name = c.name.trim(); const space = name.indexOf(" "); return { firstname: space === -1 ? name : name.slice(0, space), lastname: space === -1 ? "" : name.slice(space + 1), phone: c.phone }; }; // Same logical fields hash the same, whichever side they came from. export const fingerprint = (c: Canonical) => createHash("sha256").update(`${c.name.trim()}|${c.phone}`).digest("hex"); - One canonical shape for the fields both systems share. In our case that's name and phone. Email, owner, lifecycle stage and the rest stay in the CRM, because the WhatsApp side has nowhere to put them.
- Map in both directions through it. The CRM's
firstnameandlastnamejoin into onename;namesplits on the first space going back. "Mary Ann Fernandes" survives the round trip. - A fingerprint of the canonical fields. Because both directions hash the same logical values, the echo of a write produces the same fingerprint as the write itself. That's the loop guard.
The phone number trap
Phone numbers are the match key, so their normalisation decides whether contacts link or duplicate. CRMs store phones exactly as people typed them: "98200 11223", "098200 11223", "+44 7700 900123". The tempting normalisation, "strip everything but digits and put a + in front", turns the Indian mobile 98200 11223 into +98 2001 1223, which is an Iranian number. The contact then never matches its WhatsApp counterpart, and a message sent to it goes to the wrong country.
toE164 here applies a default country to 10-digit and 0-prefixed numbers, and keeps explicit + and 00 prefixes. That's enough for the tests; in production, use libphonenumber with the account's default region, which knows the valid lengths for every country.
The sync
import { fingerprint, fromCrm, toCrm, type Canonical, type CrmProps } from "./mapping.ts"; import { merge, type Conflict } from "./merge.ts"; export type Link = { localId: string; crmId: string; hash: string; base: Canonical }; export type Sides = { // Writes fire the other side's hooks synchronously here, the way a model hook // and a fast webhook do in production. That's what makes loops possible. readLocal: (id: string) => Canonical | null; writeLocal: (id: string | null, c: Canonical) => string; readCrm: (id: string) => CrmProps | null; writeCrm: (id: string | null, p: CrmProps) => string; findCrmByPhone: (phone: string) => string | null; }; export const createSync = (sides: Sides) => { const byLocal = new Map<string, Link>(); const byCrm = new Map<string, Link>(); // First-time links don't have both IDs until the write returns, so a create in // flight is guarded by its natural key, the phone number. const creating = new Set<string>(); const conflicts: (Conflict & { localId: string })[] = []; // The guard and the merge base are recorded BEFORE the write, so the echo of // that write finds a matching fingerprint and stops. const remember = (localId: string, crmId: string, agreed: Canonical) => { const link = { localId, crmId, hash: fingerprint(agreed), base: agreed }; byLocal.set(localId, link); byCrm.set(crmId, link); }; // CRM → local, from the CRM's webhook. It fires for our own writes too. const importContact = (crmId: string): "echo" | "updated" | "linked" | "skipped" | "conflict" => { const crm = fromCrm(sides.readCrm(crmId) ?? {}); if (!crm) return "skipped"; const link = byCrm.get(crmId); if (link?.hash === fingerprint(crm)) return "echo"; if (!link && creating.has(crm.phone)) return "echo"; if (!link) { creating.add(crm.phone); try { const localId = sides.writeLocal(null, crm); remember(localId, crmId, crm); } finally { creating.delete(crm.phone); } return "linked"; } const local = sides.readLocal(link.localId)!; const { merged, conflicts: found } = merge(link.base, local, crm); conflicts.push(...found.map((f) => ({ ...f, localId: link.localId }))); remember(link.localId, crmId, merged); sides.writeLocal(link.localId, merged); // If the merge kept a local value, send it back so the CRM converges. if (fingerprint(merged) !== fingerprint(crm)) sides.writeCrm(crmId, toCrm(merged)); return found.length ? "conflict" : "updated"; }; // local → CRM, from the local export hook on every contact change. const exportContact = (localId: string): "echo" | "updated" | "linked" | "skipped" => { const local = sides.readLocal(localId); if (!local) return "skipped"; const link = byLocal.get(localId); if (link?.hash === fingerprint(local)) return "echo"; if (!link && creating.has(local.phone)) return "echo"; if (link) { remember(localId, link.crmId, local); sides.writeCrm(link.crmId, toCrm(local)); return "updated"; } // First export: match by phone so the CRM doesn't get a duplicate. const existing = sides.findCrmByPhone(local.phone); if (existing && byCrm.has(existing)) return "skipped"; // another local contact already owns it creating.add(local.phone); try { const crmId = sides.writeCrm(existing, toCrm(local)); remember(localId, crmId, local); } finally { creating.delete(local.phone); } return "linked"; }; // Deleted in the CRM: unlink, never delete locally. The WhatsApp side holds // chat history and opt-in that the CRM knows nothing about. const crmDeleted = (crmId: string) => { const link = byCrm.get(crmId); if (!link) return null; byCrm.delete(crmId); byLocal.delete(link.localId); return link.localId; }; return { importContact, exportContact, crmDeleted, conflicts, links: byLocal }; }; Rule 1: record the guard before you write
The link row stores the fingerprint of what both sides last agreed on. Every write updates it before the write happens. When the echo arrives (the CRM's webhook for your own update, or the local hook for your own import), its fingerprint matches, and it stops there. If you update the link after the write, there's a window where the echo arrives first and gets treated as a new change. With fast webhooks and hooks that run in the same request, that window is real.
Rule 2: guard creates by a natural key
Updates have a link row to hold the guard; creates don't, because you don't have the other side's ID until the write returns. Meanwhile the other side's event for that new record can already be on its way. When we create a HubSpot contact, HubSpot's creation webhook can arrive before we've stored the new ID, and an import that finds no link creates a second local contact.
The fix is a guard keyed on something both sides know before the write: the phone number. While a create is in flight, an import or export for that phone is treated as an echo. The tests check that a new contact on either side produces exactly one write on the other, and no duplicate.
Rule 3: match by phone before creating
The first export of a local contact searches the CRM by phone before creating a new one. Most CRMs already contain your customers, and a sync that creates "Asha Patil (2)" on day one loses trust immediately. If that CRM contact is already linked to a different local contact (two local contacts sharing a phone), skip it: one link per side, and the duplicate is a data problem to show to a person, not to guess at.
HubSpot's search endpoints are rate limited to five requests per second per account, separately from the general limit of 110 requests every 10 seconds per installing account for marketplace apps. A first backfill of thousands of contacts needs pacing, and running it sequentially is the simple way to stay under both.
Rule 4: merge against the last agreed state
A single fingerprint tells you that something changed, not what. If Asha's name is edited in WhatsApp and her phone is corrected in HubSpot at the same time, the simple version takes whichever change arrives last, and silently loses the other.
import type { Canonical } from "./mapping.ts"; export type Conflict = { field: keyof Canonical; local: string; crm: string }; // Three-way merge against what both sides last agreed on. A field changed on // one side wins; a field changed differently on both is a conflict, kept as it // was locally and reported, so the two systems don't take turns overwriting it. export const merge = (base: Canonical, local: Canonical, crm: Canonical) => { const merged: Canonical = { ...local }; const conflicts: Conflict[] = []; for (const field of ["name", "phone"] as const) { const l = local[field] !== base[field]; const c = crm[field] !== base[field]; if (c && !l) merged[field] = crm[field]; else if (c && l && crm[field] !== local[field]) conflicts.push({ field, local: local[field], crm: crm[field] }); } return { merged, conflicts }; }; Storing the last agreed values (the base) on the link allows a three-way merge per field:
- Changed on one side only: take the change.
- Changed on both sides to the same value: nothing to do.
- Changed on both sides differently: a conflict. Keep one value (here, the local one), write it to the other side so both systems converge, and record the conflict for a person to look at.
Without that last step, the two systems take turns overwriting each other with every sync.
Rule 5: don't let deletions cascade by default
A contact deleted in the CRM is usually a CRM clean-up: duplicates merged, old leads removed. On the WhatsApp side, that same contact has chat history, campaign opt-in or opt-out, tags and notes. Deleting it there loses information the CRM never had. So a CRM deletion unlinks the two records and leaves the local contact alone. If you do want deletions mirrored, make it a setting someone turns on deliberately.
HubSpot's own DELETE moves a contact to the recycling bin, where it can be restored. A sync that permanently deletes the other side's copy is stricter than the CRM itself.
Verifying HubSpot's webhooks
import { createHmac, timingSafeEqual } from "node:crypto"; // HubSpot's v3 signature: base64(HMAC-SHA256(secret, method + uri + body + timestamp)). // Behind a proxy, the URL your server sees isn't the one HubSpot called, so sign // against the public URL from configuration, not one rebuilt from the request. export const verifyHubspotV3 = ( opts: { secret: string; publicUrl: string }, req: { method: string; body: string; signature?: string; timestamp?: string }, now = Date.now(), ): boolean => { const ts = Number(req.timestamp); if (!req.signature || !Number.isFinite(ts) || now - ts > 5 * 60_000) return false; // stale or missing const expected = createHmac("sha256", opts.secret).update(`${req.method}${opts.publicUrl}${req.body}${req.timestamp}`).digest("base64"); const a = Buffer.from(expected); const b = Buffer.from(req.signature); return a.length === b.length ...