Syncing HubSpot Contacts in C#: Store the Contact ID, Not Just the Email

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 sends changes through HubSpot's API. The production application runs on .NET Framework; the example here uses current .NET.

The tempting approach is to look up each user by email, create a contact if none is found, and update it otherwise. That works until the email changes. If the new address is not already associated with the existing contact, the lookup can miss it and the job can create a second record. The original contact still holds the earlier history.

The integration needs to remember which HubSpot contact belongs to the internal user. Email is useful for establishing that relationship, but it should not be the only thing keeping the relationship intact.

I store the HubSpot contact ID alongside the internal user's ID. Once that mapping exists, the normal sync updates the stored contact directly. A changed email address becomes another property to update on that record.

The initial linking step still needs care:

  1. If the user already has a stored HubSpot contact ID, use it.
  2. If the mapping is missing, check for an existing contact and confirm that it represents the intended user. Email can help with that lookup, but an API error is not evidence that the contact is absent.
  3. If a new contact is needed, create it and persist the returned ID before marking the user synchronized. Store IDs as strings, and scope the mapping to the HubSpot account if the integration serves more than one.

Creation and saving the local mapping are separate operations. HubSpot can accept a create request even if the response is lost or the database save fails. The next run needs to reconcile that uncertain outcome before creating again. Overlapping runs need coordination too, so two workers cannot independently establish different mappings for the same user.

HubSpot's search documentation says newly created or updated records may take a few moments to appear. An empty search result immediately after a write is not proof that the write failed. A custom unique property containing the internal user ID is another option for establishing identity; HubSpot supports it in its contact upsert API.

Once the mapping is established, the update is much simpler. This method assumes a reused HttpClient configured with https://api.hubapi.com/ as its BaseAddress and an appropriate bearer token. It also assumes the internal application owns the three properties being sent:

using System.Net.Http.Json;

static async Task<string> UpdateContactAsync(
    HttpClient http,
    string contactId,
    string email,
    string firstName,
    string lastName,
    CancellationToken cancellationToken = default)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(contactId);
    ArgumentException.ThrowIfNullOrWhiteSpace(email);

    using var response = await http.PatchAsJsonAsync(
        $"/crm/v3/objects/contacts/{Uri.EscapeDataString(contactId)}",
        new
        {
            properties = new
            {
                email,
                firstname = firstName,
                lastname = lastName
            }
        },
        cancellationToken);

    response.EnsureSuccessStatusCode();

    var updated = await response.Content
        .ReadFromJsonAsync<HubSpotContact>(
            cancellationToken: cancellationToken);

    if (string.IsNullOrWhiteSpace(updated?.Id))
        throw new InvalidOperationException("HubSpot returned no contact ID.");

    return updated.Id;
}

public sealed record HubSpotContact(string? Id);

The PATCH targets the contact ID, while the email travels in the request body. The method checks the HTTP status and returns the ID from the response. The caller should persist that ID and mark the update complete only after its local save succeeds. Sending only the properties this integration owns also avoids overwriting unrelated changes made by the marketing team.

When I tested the email change against the live portal, the update stayed on the existing contact and did not create another one.

A stored ID still needs a recovery path:

  • If the contact cannot be found, investigate the mapping and whether the record was deleted. Do not turn a failed update into an automatic create.
  • If the new email belongs to another contact, report the conflict for review. The sync should not choose another record to overwrite on its own.
  • Handle rate limits and temporary failures with bounded retries. Authentication and validation failures need different treatment. This small method surfaces unsuccessful responses; the surrounding job owns that policy.

For this integration, deactivating an internal user changes a status property in HubSpot. It does not delete the contact. The marketing team may still need that record and its history. HubSpot does support restorable and permanent deletions, but the retention and deletion rules belong in a separately defined workflow. An inactive user is not, by itself, an instruction to erase the CRM record.

Keeping the contact ID gives each routine update a known destination. It also leaves the exceptional cases visible, instead of letting the next email lookup quietly decide that an existing person is someone new.