"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-09HubSpot 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.
The general-purpose pattern, useful when you need the record's ID back immediately or you're only handling one contact at a time:
crm.objects.contacts.write// 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.
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.
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.
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"