waymark.networkguides → hubspot-create-or-update-contact

HubSpot: create or update a contact idempotently (upsert)

"Create a HubSpot contact" is the most common integration first step, and the most common source of duplicate records. HubSpot's contacts API has no upsert-on-create semantics on the plain create endpoint — a second POST with the same email fails or, depending on the client, silently creates a second imperfect record. These are the documented patterns and failure modes from Waymark's live route corpus, checked against HubSpot's own API reference.

Sourced from 1 live route record · regenerated 2026-07-09

Why naive "create contact" duplicates records

HubSpot enforces contact uniqueness by email, so a plain POST /crm/v3/objects/contacts against an email that already exists returns a 409 Conflict rather than updating the record. Code that doesn't handle that 409 — or that retries the same POST on transient errors — either drops the write or, worse, catches the conflict and creates a contact with a slightly different email variant to "make it work." Neither is idempotent. The fix is to decide up front whether you're creating or updating, or to hand that decision to HubSpot's native upsert endpoint.

Documented pattern

Create or update a HubSpot contact idempotently

hubspot.comsampled

Pattern 1: search-first upsert

The general-purpose pattern, useful when you need the record's ID back immediately or you're only handling one contact at a time:

  1. Authenticate with a private app Bearer token scoped for crm.objects.contacts.write
  2. POST /crm/v3/objects/contacts/search with a filter on email to check whether the contact exists
  3. If found, PATCH /crm/v3/objects/contacts/{id} with the properties to update
  4. If not found, POST /crm/v3/objects/contacts to create it
// 1. search by email
POST /crm/v3/objects/contacts/search
{
  "filterGroups": [{
    "filters": [{ "propertyName": "email", "operator": "EQ", "value": "[email protected]" }]
  }],
  "properties": ["email", "firstname", "lastname"],
  "limit": 1
}

// 2a. exists -> update
PATCH /crm/v3/objects/contacts/{id}
{ "properties": { "firstname": "Jane", "lastname": "Doe" } }

// 2b. not found -> create
POST /crm/v3/objects/contacts
{ "properties": { "email": "[email protected]", "firstname": "Jane", "lastname": "Doe" } }

The gotcha with this pattern is the race the route record flags: HubSpot's search index is eventually consistent, so a search immediately after a write from another process can miss a contact that was just created — your code then issues a POST and gets a 409, or worse, on some client configurations, silently creates a duplicate. Treat a 409 on the create step as authoritative: the error body carries the existing contact's ID, so you can fall back to PATCH against it instead of treating the conflict as a failure.

Pattern 2: the native batch/upsert endpoint

HubSpot's CRM v3 API also exposes a purpose-built upsert endpoint that removes the search round-trip and the race entirely by letting HubSpot decide create-vs-update server-side, keyed on a property you choose:

POST /crm/v3/objects/contacts/batch/upsert
{
  "inputs": [
    {
      "idProperty": "email",
      "id": "[email protected]",
      "properties": { "email": "[email protected]", "firstname": "Jane", "lastname": "Doe" }
    }
  ]
}

Per HubSpot's API reference, idProperty is only needed when matching on something other than the internal record ID (hs_object_id) — set it to email (or another unique property) and pass that value as id. You cannot use hs_object_id itself as the idProperty, since that field is system-generated on create. The endpoint accepts up to 100 records per batch call, matching the route record's note on batch sizing. Some integrators have reported the batch endpoint returning a single 409 for the whole batch when one input collides with an existing contact, rather than a partial 207 per-record result — worth handling defensively (retry the batch minus the offending record) until you've confirmed current behavior against your own portal.

Auth and scopes

Both patterns use a private app access token sent as a Bearer token, with the app granted the crm.objects.contacts.write scope (read access is bundled in most default private-app scope sets, but confirm read is present if you're doing the search-first pattern). Custom contact properties must be created via HubSpot's properties API before you write to them — an unrecognized property name in either endpoint's request body returns a 400, not a silent ignore.

How agents avoid this automatically

Agents wired to Waymark's MCP server pull this route (and its gotchas) at plan time instead of rediscovering the 409 handling and index-lag race in production. Reads are free and keyless.

# Any MCP client — one call, no key:
waymark_query({ "task": "create or update a hubspot contact idempotently" })

# Or plain HTTP:
curl -s "https://mcp.waymark.network/search?q=hubspot%20create%20contact"
Wire up waymark_query — free → Need a route for your exact stack? $25, delivered in 24h → For teams: verify your whole stack — Pilot $750/mo →