I recently built an integration for a client that keeps HubSpot contacts in sync with an internal user list. A console job runs every five minutes, compares the two sides, and pushes changes to HubSpot through their API. The job itself lives in a long-serving .NET Framework application; the samples below use current .NET for clarity, but the pattern is identical. Simple enough on the surface, and one easy trap shows up in the first version of a job like this. It looks like this:
var contact = await FindContactByEmailAsync(user.Email);
if (contact is null)
{
await CreateContactAsync(user);
}
else
{
await UpdateContactAsync(contact.Id, user);
}
Find the contact by email. Create it if missing, update it if found. Reasonable, readable, and the happy-path tests pass. Then someone gets married, or the company changes domains, and a user's email address changes. Now the search for the new address finds nothing, so the job creates a brand new contact. The old contact is still sitting in HubSpot under the old address, marked active, holding all the history. You have a duplicate and an orphan, and the job that caused it reports success.
The root mistake is treating the email address as the identity. It is not. It is a property of the identity, and properties change. HubSpot already knows this, which is why every contact has a stable record id. That id is what your system should remember. The first time you create or find a contact, store the HubSpot id in your own database next to the user. From then on, the normal sync path does not search at all. It goes straight to the record:
if (user.HubSpotContactId is null)
{
user.HubSpotContactId =
await ResolveContactIdByEmailAsync(http, user.Email)
?? await CreateContactAsync(http, user);
// persist user.HubSpotContactId to your database here
}
await http.PatchAsJsonAsync(
$"/crm/v3/objects/contacts/{user.HubSpotContactId}",
new
{
properties = new
{
email = user.Email,
firstname = user.FirstName,
lastname = user.LastName
}
});
Notice what fell out of that for free: a rename is no longer a special case. The email address is just one more property in the PATCH, updated in place on the same contact, with every association and every piece of history intact. When I proved this out against the live portal, the update stayed on the existing contact. The old address no longer identified that contact, and nothing new was left behind. Search by email happens once in a contact's life, on first sight, and here is that piece:
private static async Task<string?> ResolveContactIdByEmailAsync(
HttpClient http, string email)
{
var body = new
{
filterGroups = new[]
{
new
{
filters = new[]
{
new { propertyName = "email", @operator = "EQ", value = email }
}
}
},
limit = 1
};
var response = await http.PostAsJsonAsync(
"/crm/v3/objects/contacts/search", body);
response.EnsureSuccessStatusCode();
var result = await response.Content
.ReadFromJsonAsync<ContactSearchResult>();
return result?.Results?.FirstOrDefault()?.Id;
}
The stored id is not a substitute for error handling. If the contact was deleted or merged in HubSpot, the update can come back not found, and that is a signal to stop and recover deliberately. An automatic create at that moment is how you manufacture the exact duplicate this pattern exists to prevent. Likewise, if the new email already belongs to a different contact, the sync should stop and report the conflict for review rather than guess.
One more rule from the same project: for this integration, the sync never deletes a contact. When a user is deactivated on our side, the job flips a status property in HubSpot and walks away. Part of the reason is practical: deletion in a CRM destroys history, breaks associations, and cannot be taken back by a job that runs unattended at three in the morning. And part of it is just good manners: those contact records belong to the marketing team, and a background job has no business destroying another team's data. Deactivation is reversible, auditable, and honest about what actually happened, which is that a person became inactive, not that they never existed. Any legal deletion or retention requirement belongs in its own deliberate workflow, decided by people rather than a timer.
None of this is specific to HubSpot. Salesforce, Dynamics, Mailchimp, any system your code talks to: if the remote side has a stable id, store it, and treat every human-readable field as changeable, because sooner or later all of them are. The integrations that age well are the ones that remember who someone is, not just what they were called.