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".
Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.
Quelldateien prüfen
Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fügen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prüfen und installieren.
Ein direkter Befehl überspringt den Prüf-Prompt. Prüfen Sie die Quelle, bevor Sie ihn ausführen.
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".
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:
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.
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.
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.
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.
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.
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.
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.
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.
typeHubSpotPropertyType = "string" | "number" | "date" | "datetime" | "bool" | "enumeration";
interfacePropertyDefinition {
name: string;
type: HubSpotPropertyType;
fieldType: "text" | "number" | "date" | "booleancheckbox" | "select" | "textarea";
label: string;
groupName: string;
description?: string;
}
asyncfunctionensureProperty(token: string,
objectType: "contacts" | "companies",
prop: PropertyDefinition,
): Promise<void> {
// Check if it exists firstconst check = awaitfetch(
`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) {
thrownewError(
`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) {
thrownewError(`Unexpected status ${check.status} checking property ${prop.name}`);
}
// Create itconst create = awaitfetch(
`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) {
thrownewError(`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).
import { createHash } from"crypto";
interfaceProductEvent {
eventId: string; // UUID from your event stream — unique per occurrenceemail: 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 retriedfunctionidempotencyKey(eventId: string, propertyName: string): string {
returncreateHash("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 NOTHINGasyncfunctionisAlreadyProcessed(key: string, redis: RedisClient): Promise<boolean> {
return (await redis.get(`hs_sync:${key}`)) !== null;
}
asyncfunctionmarkProcessed(key: string, redis: RedisClient): Promise<void> {
// 48h TTL — cover the worst realistic retry windowawait redis.setex(`hs_sync:${key}`, 172_800, "1");
}
Counter properties require special handling. You cannot make "increment by 1" idempotent with a simple deduplication key on the write side, because HubSpot properties are absolute values, not deltas. The only safe pattern is:
Read the current value from HubSpot before writing.
Compute the new value in your code.
Write the absolute new value.
Use the idempotency key to skip the entire operation (read + write) if this event was already processed.
A token-bucket queue that collects events for up to 5 seconds or until 100 accumulate, then flushes one batch. One batch = one API call. This converts an event storm of 10K events/minute into 100 API calls/minute — well within the 600 calls/minute budget.
The queue's contract, all load-bearing:
push() flushes immediately at 100 events (the HubSpot batch/update limit) or after a 5s timer, whichever comes first — never one call per event.
One flush = one crm/v3/objects/contacts/batch/upsert call keyed on idProperty: "email" (auto-creates on miss), wrapped in withRetry.
Every property value is serialized to a string — the batch API rejects non-string values.
The response goes to the 207 Multi-Status handler (next section); failed rows land in the dead-letter queue, never silently dropped.
5. 207 Multi-Status handling with dead-letter queue
This is the most commonly mishandled failure mode. HubSpot returns HTTP 207 when a batch partially succeeds. The response body contains a results array (successes) and an errors array (failures). Your code must parse both.
When an event arrives for an email that is not yet in HubSpot, batch/update silently drops the record. The correct path is batch/upsert (used in section 4 above) which creates the contact if the email is not found. If you are using batch/update for other reasons, implement an explicit create-on-miss fallback:
When to use search+create vs upsert:batch/upsert with idProperty: "email" is simpler and handles both cases atomically in one API call. Use the search+create pattern only when you need the contact ID back synchronously before writing other properties, or when building a single-contact sync path (not a batch path).
7. Rate limit wiring
Wire the retry helper to read Retry-After from 429 responses and respect it. Do not implement a fixed delay — the Retry-After value is the only one HubSpot guarantees will not extend your suspension.
Retry with exponential backoff and jitter; DLQ after max attempts
207 is a success status code — do not treat it as success. A 207 from batch/update or batch/upsert means the request completed but individual records inside the batch may have failed. Always inspect body.errors.
Examples
Bootstrap: define your custom property schema
// Run once at service startupconstPRODUCT_PROPERTIES: PropertyDefinition[] = [
{
name: "hs_product_last_active_date",
label: "Last Active Date (Product)",
type: "datetime",
fieldType: "date",
groupName: "product_signals",
description: "Last timestamp the contact triggered a product event",
},
{
name: "hs_product_total_sessions",
label: "Total Sessions (Product)",
type: "number",
fieldType: "number",
groupName: "product_signals",
description: "Lifetime session count from product backend",
},
{
name: "hs_product_current_plan",
label: "Current Plan (Product)",
type: "enumeration",
fieldType: "select",
groupName: "product_signals",
description: "Plan tier from billing system",
},
];
for (const prop ofPRODUCT_PROPERTIES) {
awaitensureProperty(token, "contacts", prop);
}