Claude
Skills
Sign in
Back

hubspot-product-event-sync

Included with Lifetime
$97 forever

Sync backend product events into HubSpot contact and company custom properties using idempotent batched updates — the Segment integration pattern without Segment. Use when you need to push server-side behavioral signals (feature usage, session counts, last-seen timestamps, trial milestones) into HubSpot CRM properties so sales and marketing can act on product data without a CDP in the stack. Trigger with "hubspot product events", "sync events to hubspot", "hubspot custom properties", "hubspot event pipeline", "hubspot segment alternative", "push usage data to hubspot".

Backend & APIshubspotproduct-analyticsevent-syncintegration-engineering

What this skill does


# HubSpot Product Event Sync

## Overview

Push backend product events into HubSpot custom contact and company properties — the core pattern of a Segment-style integration, built directly against the HubSpot CRM API without a CDP middleman. This is not a tutorial on HubSpot setup. It is the code your data pipeline runs at 3am when a product launch generates a 10K events/minute storm, when a network hiccup causes the same batch to be retried and your "total sessions" counter doubles, when a property type mismatch silently truncates numbers, and when your contact lookup fails because a new user signed up ten seconds ago and HubSpot doesn't have them yet.

The six production failures this skill prevents:

1. **Non-idempotent updates** — the same event processed twice (retry after network failure) increments a counter twice. "Last seen" survives duplication; "total sessions" does not. Idempotency keys must be event-level, not request-level.
2. **Property type mismatch** — writing a number to a HubSpot `string` property returns HTTP 200 with the value coerced silently. The stored data is wrong; no error surfaces. Validate property types before writing.
3. **Rate-limit burnout from event storms** — a product launch generates 10K events/minute. Naive sync exhausts the 100 req/10s burst budget in under two seconds. A token-bucket queue is non-optional.
4. **Contact not found** — the product event carries an email that HubSpot does not have yet. The batch update silently drops the record. Upsert-by-email or auto-create is the correct path.
5. **Batch partial failure (207 Multi-Status)** — `POST /crm/v3/objects/contacts/batch/update` returns 207 when some records succeed and others fail. Treating 207 as success causes silent data loss at scale. Parse the per-object `errors` array.
6. **Custom event vs custom property confusion** — HubSpot has two different systems: custom behavioral events (Marketing Hub Enterprise, timeline-visible) and custom contact/company properties (available on all tiers, stored as structured data on the record). Using the wrong mechanism for the use case leads to wrong attribution, missing data, or a surprise $3K/month plan upgrade.

## Prerequisites

- Node.js 18+ or Python 3.10+
- HubSpot private app token with scopes: `crm.objects.contacts.read`, `crm.objects.contacts.write`, `crm.objects.companies.read`, `crm.objects.companies.write`, `crm.schemas.contacts.read`, `crm.schemas.contacts.write`
- Custom properties already defined in HubSpot (or use the property-create flow in this skill to create them)
- Your backend event stream: Kafka topic, SQS queue, webhook receiver, or polling loop — the sync layer is transport-agnostic
- A dead-letter store for failed records: a Postgres table, Redis sorted set, or S3 prefix — anything you can replay from

## Instructions

Build in this order. Each section neutralizes one production failure mode.

### 1. Decide: custom property or custom behavioral event?

Get this wrong and you build the right pipeline into the wrong HubSpot system. Custom properties and custom behavioral events are entirely separate surfaces.

| Dimension | Custom property | Custom behavioral event |
|---|---|---|
| HubSpot tier | All tiers | Marketing Hub Enterprise only |
| Visible in | Contact/company record sidebar | Record timeline + Behavioral Events report |
| Query in lists | Yes (`contact.hs_last_active_date > 7d ago`) | Limited (via list enrollment criteria) |
| Attribution | No native attribution | Yes (can be tied to campaign attribution) |
| API | `/crm/v3/objects/contacts/batch/update` | `/events/v3/send` |
| Best for | Product data, counts, timestamps, tier flags | Marketing touchpoints, funnel stages, UTM-tagged actions |
| Wrong for | Timeline visibility of behavioral sequences | Querying by property value in segmentation lists |

**Rule of thumb:** if your sales team needs to filter contacts by a product signal (e.g., "show me contacts where last_active_date > 14 days ago"), it goes in a custom property. If your marketing team needs to see a behavioral sequence on the contact timeline, it goes in a custom behavioral event. Most product-to-CRM pipelines use custom properties exclusively.

### 2. Verify and create property definitions

Before writing any data, confirm that the target property exists and has the right type. HubSpot returns 200 even when coercing an incompatible value, so the validation must happen on your side.

```typescript
type HubSpotPropertyType = "string" | "number" | "date" | "datetime" | "bool" | "enumeration";

interface PropertyDefinition {
  name: string;
  type: HubSpotPropertyType;
  fieldType: "text" | "number" | "date" | "booleancheckbox" | "select" | "textarea";
  label: string;
  groupName: string;
  description?: string;
}

async function ensureProperty(
  token: string,
  objectType: "contacts" | "companies",
  prop: PropertyDefinition,
): Promise<void> {
  // Check if it exists first
  const check = await fetch(
    `https://api.hubapi.com/crm/v3/properties/${objectType}/${prop.name}`,
    { headers: { Authorization: `Bearer ${token}` } },
  );

  if (check.status === 200) {
    const existing = await check.json();
    if (existing.type !== prop.type) {
      throw new Error(
        `Property type mismatch: ${prop.name} is ${existing.type} in HubSpot, ` +
        `but your schema declares it as ${prop.type}. ` +
        `Mismatched writes return 200 with silently wrong data. ` +
        `Either rename the property or migrate the type — you cannot update a property type in place.`,
      );
    }
    return; // exists and type matches — nothing to do
  }

  if (check.status !== 404) {
    throw new Error(`Unexpected status ${check.status} checking property ${prop.name}`);
  }

  // Create it
  const create = await fetch(
    `https://api.hubapi.com/crm/v3/properties/${objectType}`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        name: prop.name,
        label: prop.label,
        type: prop.type,
        fieldType: prop.fieldType,
        groupName: prop.groupName,
        description: prop.description ?? "",
      }),
    },
  );

  if (!create.ok) {
    throw new Error(`Failed to create property ${prop.name}: ${await create.text()}`);
  }
}
```

Call `ensureProperty` at service startup, not per-event. Property definitions are stable; checking them on every event wastes rate-limit budget and adds latency.

### 3. Idempotency key design

An idempotency key is the contract that makes retry safe. The key must uniquely identify a specific value written to a specific property for a specific event occurrence. The wrong key design causes either over-deduplication (missing legitimate updates) or under-deduplication (counter inflation on retry).

```typescript
import { createHash } from "crypto";

interface ProductEvent {
  eventId: string;          // UUID from your event stream — unique per occurrence
  email: string;
  properties: Record<string, string | number | boolean>;
  occurredAt: number;       // Unix ms
}

// Idempotency key = hash(eventId + propertyName)
// Scope per-property, not per-event, so you can track exactly which property write was retried
function idempotencyKey(eventId: string, propertyName: string): string {
  return createHash("sha256")
    .update(`${eventId}:${propertyName}`)
    .digest("hex")
    .slice(0, 16); // 16 hex chars = 8 bytes = 64 bits of collision resistance
}

// Store processed keys with TTL to cap memory. Redis SETEX is canonical.
// Postgres alternative: INSERT INTO idempotency_keys (key, processed_at) ON CONFLICT DO NOTHING
async function isAlreadyProcessed(key: string, redis: RedisClient): Promise<boolean> {
  return (await redis.get(`hs_sync:${key}`)) !== null;
}

async function markProcessed(key: string, redis: RedisClient): Promise<void> {
  // 48h TTL — cover the worst realistic retry window
  await red

Related in Backend & APIs