GTM Galaxy

Article

The Tombstone Trap: Why CRM Merges Resurrect Zombie Records

Most GTM engineers treat a CRM record ID as a permanent, immutable anchor. We build our Postgres tables, webhook listeners, and Segment schemas around the logic that 0015f00000Gv789 is the one true source for a specific account.

This assumption is a liability. In any CRM used by an actual sales team, deduplication is a constant, messy background process. When two records merge, one survives and the other becomes a "tombstone"—a soft-deleted record that points to a new canonical ID.

If your sync architecture isn't built to resolve these tombstones, it will eventually resurrect them. You’ll end up with "zombie" records in your downstream systems that trigger duplicate outreach, break attribution, and turn your billing data into a crime scene.

The Mechanics of the Soft Delete

To build a resilient pipeline, you have to look at how CRMs handle merges under the hood. They don't just delete the losing record; they create a lineage that standard API calls are designed to ignore.

Salesforce: The MasterRecordId

In Salesforce, merging Account A into Account B doesn't purge Account A. Instead, Account A is soft-deleted: its IsDeleted flag is set to true, and the MasterRecordId field is populated with Account B's ID.

The trap is in the query behavior. A standard SOQL query—SELECT Id, Name FROM Account—will never return Account A. To see the lineage, you must explicitly use the queryAll() API or append ALL ROWS to your queries.

-- The only way to find where your old records went
SELECT Id, MasterRecordId, IsDeleted 
FROM Account 
WHERE IsDeleted = true 
AND MasterRecordId != null 
ALL ROWS

If your ingestion pipeline only polls for updated records where IsDeleted = false, you are effectively blind to the fact that an ID you currently track has been deprecated and remapped.

HubSpot: The Event-Based Merge

HubSpot handles this through its Webhooks API, but the logic is event-driven rather than state-driven. When a merge occurs, HubSpot fires a contact.merge or company.merge event.

The payload identifies the primaryObjectId (the winner) and the mergedObjectId (the loser). Unlike Salesforce, where you can query the tombstone state at any time, HubSpot requires you to catch the event or specifically inspect the merged-vids in the identity profiles of a contact. If your listener only processes propertyChange events, you’ll miss the merge entirely, leaving two disconnected records in your warehouse that HubSpot now considers one.

How Zombies are Resurrected

A zombie record occurs when a downstream system attempts to "fix" a CRM record it thinks is missing.

Consider a typical bi-directional sync failure:

  1. Your warehouse has a record for Lead_123.
  2. In the CRM, a rep merges Lead_123 (loser) into Lead_456 (winner).
  3. Later, an enrichment tool updates Lead_123 in your warehouse.
  4. Your sync tool attempts to push that update to the CRM using the Lead_123 ID.
  5. The CRM API returns a 404 Not Found because the record is soft-deleted.
  6. A naive sync treats that 404 as an accidental deletion and re-creates the record in the CRM to "fix" the state.

You have now successfully resurrected a duplicate that a human spent time cleaning up.

Building an Alias Resolution Layer

To solve this, GTM engineers should implement an explicit Alias Resolution Layer. This is a persistent mapping table that acts as a traffic controller for every ID entering or leaving your systems.

The Schema:

legacy_id canonical_id source_system discovered_at
0015...A 0015...B salesforce 2024-05-10 09:00
vid_123 vid_456 hubspot 2024-05-10 09:15

The Implementation Logic: Every time your ingestion engine receives an ID—whether from a webhook, a bulk export, or a message queue—it must pass through an alias check:

  1. Check the Alias Cache: Is the incoming ID in the legacy_id column?
  2. If Yes: Replace the incoming ID with the canonical_id. Route the update to the surviving record.
  3. If No: Proceed as normal. However, if the CRM returns a 404, trigger a specific "Tombstone Check" (e.g., queryAll in Salesforce) before assuming the record is truly gone.

The Engineering Trade-offs

Maintaining identity lineage isn't a free lunch. There are three operational realities you have to manage:

1. The Latency Tax Adding an alias lookup to every sync operation introduces a database read. In high-throughput pipelines, this can become a bottleneck. We solve this by keeping the alias table in a fast key-value store like Redis. The write happens when the merge event is detected; the read happens on every inbound update.

2. Circular Merges and Chains Records are often merged multiple times. A is merged into B, then B is merged into C. Your resolution logic must be recursive, or your ingestion process should "flatten" the table so that A points directly to C. Without flattening, your sync will eventually fail when it tries to update a record that is itself a tombstone.

3. The CDP Fallacy Many CDPs claim to handle identity resolution out of the box. While they are great at mapping an email address to a cookie ID, they rarely handle the internal object-level merges of a CRM. If you send a Salesforce delete event to a CDP, it will often just drop the record. You still need to manage the CRM-specific lineage at the ingestion layer to ensure that historical data attached to the old ID is correctly merged into the new one.

Operational Reality

You cannot solve this problem by restricting merge permissions in the CRM UI. Merging is a sign of a healthy, active sales team cleaning up their data.

If your code assumes a 404 from an API means a record should be re-created—or if you ignore IsDeleted records in your exports—you are sitting on a tombstone trap. High-integrity GTM systems don't just track what currently exists; they maintain a map of what used to be.

— C.B.