How to Architect a Bidirectional HubSpot Sync (Without Infinite Loops)
A technical guide to architecting two-way HubSpot sync that handles strict API rate limits, prevents infinite webhook loops, and normalizes HubSpot's nested schema without custom code.
Syncing customer data bidirectionally between your application and HubSpot means both systems can create, update, and delete records, with changes propagating in both directions without data loss, duplication, or infinite loops. If you've shipped (or attempted to ship) this, you already know the hard parts aren't the HTTP calls. It's the rate limits that throttle you mid-sync, the webhook events that arrive late and out of order, and the nested property schema that makes field mapping feel like translating between two different languages.
This guide breaks down the exact technical challenges of two-way HubSpot integration, the architectural patterns that prevent the most common failures, and how a declarative, data-driven approach can replace thousands of lines of custom integration code.
The Enterprise Demand for Bidirectional HubSpot Sync
One-way sync — pushing data into HubSpot — is table stakes. The real business value comes from bidirectional sync: your product writes engagement signals, usage metrics, and enrichment data into HubSpot while simultaneously pulling deal updates, lifecycle stage changes, and rep activity back into your app.
Why does this matter so much? Because stale CRM data is a revenue problem, not just an annoyance. Poor data quality costs organizations at least $12.9 million a year on average, according to Gartner research. CRM activity accounts for roughly 18% of rep time, and with up to 25% of that time wasted on bad or disorganized data, a business can lose hundreds of thousands of dollars in productivity annually. In one survey of over 1,250 companies, 44% estimated they lose over 10% in annual revenue due to poor-quality CRM data.
When your product captures a lead score change, a meeting outcome, or a usage milestone and that data doesn't land in HubSpot before the next rep picks up the phone, it's a missed opportunity. And when a deal stage change in HubSpot doesn't make it back into your product in time to trigger an automated workflow, your customer experience suffers silently.
Traditional batch ETL creates a fundamental delay. For fast-moving enterprise sales cycles, a six-hour sync interval is an eternity. You need real-time, event-driven synchronization.
Real-time is not the same as instant. Between CRM event delivery, your internal message queue, retries, and rate-limit backoff, "real-time" usually means seconds, sometimes minutes, and occasionally "we'll reconcile later." If your stakeholders expect a hard SLA of 200ms, reset expectations early.
The "Vampire Record" Problem: Why Two-Way Syncs Create Infinite Loops
The moment both systems can write to the same record, you open the door to infinite loops. The mechanics are deceptively simple:
- A sales rep updates a contact's phone number in HubSpot.
- HubSpot fires a
contact.propertyChangewebhook to your app. - Your app processes the webhook and updates the corresponding record in your database.
- Your sync logic detects the internal change and pushes it back to HubSpot.
- HubSpot detects the update and fires another webhook.
- Go to step 3. Repeat forever.
These are called "vampire records" — data entries that bounce between systems indefinitely, feeding on your API quota without ever settling. For a deeper treatment of this problem and the loop-prevention patterns that work at scale, see our architect's guide to bi-directional API sync.
sequenceDiagram
participant Rep as Sales Rep
participant HS as HubSpot
participant App as Your App
participant DB as Your Database
Rep->>HS: Updates phone number
HS->>App: Webhook: contact.propertyChange
App->>DB: Update local record
DB->>App: Change detected
App->>HS: PATCH /contacts/{id}
HS->>App: Webhook: contact.propertyChange
Note over App,HS: Infinite loop beginsWhy Naive Fixes Fail
The "just compare timestamps" approach fails in practice. HubSpot's hs_lastmodifieddate has second-level granularity, and network latency means your write and the subsequent webhook can land within the same second. You need a combination of strategies:
- Origin tagging: Stamp every outbound write with a source identifier (e.g., a custom property like
_sync_source). When a webhook arrives, check if the change originated from your system and skip it if so. - Write receipts: Maintain a short-lived cache of recent writes (record ID + field + value). If an inbound webhook matches a write you just made, suppress it.
- Idempotent upserts: Design every write operation so applying it twice produces the same result. This turns accidental re-delivery from a data corruption risk into a no-op.
HubSpot's Webhook Delivery Model Makes This Harder
HubSpot's delivery model punishes sloppy handlers. HubSpot can send up to 100 events in a single webhook request, uses a default concurrency limit of 10 in-flight webhook requests per installed account, and retries failed notifications up to 10 times. HubSpot also expects a webhook batch to be acknowledged within 5 seconds. If you do slow work before returning 2xx, you create backlog, retries, and duplicate processing.
That's why the inbound path should be boring: validate the signature, persist the raw payload, respond fast, and let a worker do the expensive work later. As we've covered in our lessons on designing reliable webhooks, HubSpot's current request-validation guidance uses the X-HubSpot-Signature-V3 header with HMAC SHA-256. Requests older than 5 minutes should be rejected.
app.post('/hubspot/webhooks', rawBodyMiddleware, async (req, res) => {
if (!isValidHubSpotSignatureV3(req)) {
return res.status(401).end();
}
const batchId = await webhookStore.append(req.body);
res.status(204).end();
for (const event of req.body) {
await queue.enqueue('hubspot-contact-change', {
batchId,
objectId: event.objectId,
eventType: event.subscriptionType,
occurredAt: event.occurredAt
});
}
});That handler is intentionally boring. The real loop prevention lives in state you control: a dedupe key for inbound events, a fingerprint of the last applied remote state, an outbound write journal, and field ownership rules.
HubSpot fires multiple webhook events for a single business action. When creating a deal, you might receive the creation event plus separate property change notifications for each field. These often arrive out of order. Your deduplication strategy must handle this.
What a HubSpot Webhook Actually Delivers
Before building any loop prevention logic, you need to know what HubSpot actually sends. The webhook request arrives as an array of events - not a single event - with signature headers for validation.
Request headers:
POST /your-endpoint HTTP/1.1
Content-Type: application/json
X-HubSpot-Signature-v3: <base64-encoded-hmac-sha256>
X-HubSpot-Request-Timestamp: 1723651850844
X-HubSpot-Signature-Version: v3
Payload (array of up to 100 events):
[
{
"eventId": 1234567891,
"subscriptionId": 98766,
"portalId": 62515,
"appId": 123456,
"occurredAt": 1723651850844,
"subscriptionType": "contact.propertyChange",
"attemptNumber": 0,
"objectId": 987654,
"changeSource": "CRM_UI",
"propertyName": "lifecyclestage",
"propertyValue": "customer"
},
{
"eventId": 1234567892,
"subscriptionId": 98767,
"portalId": 62515,
"appId": 123456,
"occurredAt": 1723651851200,
"subscriptionType": "contact.propertyChange",
"attemptNumber": 0,
"objectId": 987654,
"changeSource": "API",
"propertyName": "phone",
"propertyValue": "+1-415-555-0100"
}
]The fields that matter most for bidirectional sync:
eventId: Unique per event. Use this as your idempotency key. HubSpot uses at-least-once delivery, so duplicates will happen.changeSource: Where the change originated. Common values includeCRM_UI(manual edit in HubSpot),API(any API call),INTEGRATION,BATCH_UPDATE,FORMS, andWORKFLOW. This helps with loop detection but has a blind spot -APIdoesn't tell you whether the change came from your app or another integration on the same account.attemptNumber: Starts at 0, increments on retries. If you're consistently seeing values above 0, your endpoint is returning errors or timing out.occurredAt: Millisecond-precision Unix timestamp of when the change happened. Use this for ordering, not the time your server received the request.propertyName/propertyValue: Only the changed property and its new value - not the full record. You'll need a follow-up GET to/crm/v3/objects/contacts/{objectId}for the complete object.
For V3 signature validation, concatenate requestMethod + requestUri + requestBody + timestamp (where timestamp comes from X-HubSpot-Request-Timestamp), compute HMAC-SHA256 using your app's client secret, and Base64-encode the result. Use constant-time comparison to prevent timing attacks, and reject any request where the timestamp is older than 5 minutes.
HubSpot webhook payloads are intentionally lightweight. They contain only event metadata and the changed property, not the full record. This keeps delivery fast but means you'll need at least one GET request per event to hydrate the full object for your sync. Budget that API call into your rate limit planning.
Navigating HubSpot API Rate Limits and Pagination
HubSpot's rate limiting is layered, and the layers interact in ways that will surprise you if you only read the top-level docs.
The Rate Limit Landscape
| App Type | Burst Limit | Daily Limit |
|---|---|---|
| Public OAuth apps | 110 requests / 10 seconds | Varies by customer tier |
| Private apps (Pro/Enterprise) | 190 requests / 10 seconds | 650K–1M requests/day |
| CRM Search API | 5 requests / second (account-wide) | Shared with daily quota |
Here's the part that bites: the CRM Search API burst limit is 5 requests per second, and that cap is shared by everything under one HubSpot account. If your app and two other integrations are all hitting the Search endpoint, you're sharing 5 requests per second across all of them. This is an account-level ceiling, not a per-app one.
Search responses also do not include the standard X-HubSpot-RateLimit-* headers, so you need your own pacing logic. Treat search as a separate budget, not as spare capacity from your core CRUD calls.
Why This Matters for Bidirectional Sync
A bidirectional sync has two hot paths that both consume API quota:
- Inbound (HubSpot → your app): Webhooks reduce polling, but you still need to fetch full records after receiving a webhook payload (which only contains the changed property, not the full object). That's at least one GET per webhook event.
- Outbound (your app → HubSpot): Every create, update, or upsert is a write call. Batch endpoints help (up to 100 records per batch call), but you still burn quota.
When you combine these with search calls for deduplication lookups, you can hit the daily or burst limits faster than you'd expect — especially during initial backfill or end-of-quarter pipeline pushes.
Surviving the Limits
Batch aggressively. HubSpot's batch read and batch upsert endpoints handle up to 100 records per call. A sync that naively does record-by-record operations burns 100x more quota than one that batches intelligently. HubSpot also supports up to 200 records per Search API response, so use larger page sizes.
Implement exponential backoff with jitter. When you hit a 429, a naive fixed delay causes all throttled workers to wake up simultaneously and immediately hit the limit again. Randomize your retry intervals. And be aware of the escalation: after hitting standard rate limits, if a system makes 10 requests resulting in 429 errors within a second, it gets blocked for one full minute.
Handle 423 Locked responses. During high-volume write bursts, HubSpot can return 423 Locked and recommends waiting at least 2 seconds between requests when you hit that path.
Partition queries for large datasets. HubSpot's Search API enforces a hard cap of 10,000 total results per query, with search request bodies limited to 3,000 characters. If your customer has 50,000 contacts, a paginated query will fail after hitting the ceiling. Break the sync into smaller chunks by querying specific date ranges (e.g., createdate BETWEEN X AND Y).
Don't rely on search for write verification. Search can lag recently created or updated objects. If you need immediate confirmation of a write, read the object directly rather than searching for it.
Webhook calls made via HubSpot workflows do not count toward the API rate limit. This is a useful escape hatch, but it only applies to workflow-triggered webhooks, not the Webhooks API subscriptions you'd typically use for a bidirectional sync.
For a deeper dive into handling real-time CRM sync at enterprise scale, read our guide to architecting real-time CRM syncs.
Rate-Limit-Aware Pacing in Practice
A naive sleep(100) between every request wastes throughput. A token bucket that tracks your remaining budget across all concurrent workers performs much better.
class HubSpotRateLimiter {
private tokens: number;
private readonly maxTokens: number;
private readonly refillInterval = 10_000; // 10 seconds
private lastRefill = Date.now();
constructor(burstLimit: number) {
this.maxTokens = burstLimit;
this.tokens = burstLimit;
}
async acquire(): Promise<void> {
this.refill();
if (this.tokens > 0) {
this.tokens--;
return;
}
const waitMs = this.refillInterval - (Date.now() - this.lastRefill);
await new Promise(r => setTimeout(r, waitMs + Math.random() * 1000));
this.refill();
this.tokens--;
}
private refill() {
const now = Date.now();
if (now - this.lastRefill >= this.refillInterval) {
this.tokens = this.maxTokens;
this.lastRefill = now;
}
}
}
// Public OAuth app: 110 req/10s - leave headroom for other integrations
const apiLimiter = new HubSpotRateLimiter(100);
// Search API: 5 req/s account-wide - keep a separate, stricter limiter
const searchLimiter = new HubSpotRateLimiter(4);Pacing rules that matter for bidirectional sync:
- Leave headroom. If your burst limit is 110/10s, pace to ~100. Other integrations on the same HubSpot account share the daily quota, and you can't control their behavior.
- Separate your search budget. The CRM Search API's 5 req/s limit is account-wide, not per-app. Track it with its own limiter.
- Respect
Retry-After. When you get a 429, HubSpot includes aRetry-Afterheader. Honor it - don't guess a backoff interval. - Watch for 423 Locked. During burst writes, HubSpot returns
423 Locked. Back off for at least 2 seconds before retrying. - Add jitter. After a 429, don't retry at exactly the backoff interval. Add 0-1 seconds of random delay to prevent thundering herd effects when multiple workers retry simultaneously.
Data Mapping: The Hidden Complexity of HubSpot's API
If you've only worked with flat REST APIs, HubSpot's CRM object schema will feel unfamiliar. It's not bad — it's just different in ways that require explicit handling at your integration boundary.
HubSpot's Nested Properties Model
HubSpot doesn't return contact fields at the top level. Everything lives inside a properties object:
{
"id": "501",
"properties": {
"firstname": "Jane",
"lastname": "Chen",
"email": "jane@acme.com",
"hs_additional_emails": "jane.chen@personal.com;jchen@old-company.com",
"phone": "+1-415-555-0100",
"mobilephone": "+1-415-555-0101",
"hs_whatsapp_phone_number": "+1-415-555-0102"
},
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2025-06-20T14:15:00Z"
}Compare this to Salesforce, which returns flat PascalCase fields:
{
"Id": "003xxx",
"FirstName": "Jane",
"LastName": "Chen",
"Email": "jane@acme.com",
"Phone": "+1-415-555-0100",
"MobilePhone": "+1-415-555-0101",
"CreatedDate": "2024-01-15T10:30:00Z"
}This isn't just a cosmetic difference. It affects every part of your sync pipeline:
- Email parsing: HubSpot stores additional emails as a semicolon-delimited string in
hs_additional_emails. You need to split and normalize that into an array. - Phone numbers: HubSpot uses separate properties for
phone,mobilephone, andhs_whatsapp_phone_number. Salesforce has six phone fields (Phone,Fax,MobilePhone,HomePhone,OtherPhone,AssistantPhone). Your unified model needs to absorb both. - Custom fields: In HubSpot, custom fields live alongside standard fields in the
propertiesobject. In Salesforce, they're distinguished by a__csuffix. Identifying which fields are custom requires different logic per CRM.
The filterGroups Headache
Searching and filtering in HubSpot is a world apart from standard query parameters. You can't just pass ?email=jane@acme.com. You must issue a POST request to the search endpoint with a nested filterGroups payload:
{
"filterGroups": [
{
"filters": [
{ "propertyName": "email", "operator": "EQ", "value": "jane@acme.com" },
{ "propertyName": "lastname", "operator": "CONTAINS_TOKEN", "value": "Chen" }
]
}
]
}Salesforce achieves the same thing with SOQL:
SELECT Id, FirstName, LastName FROM Contact
WHERE Email = 'jane@acme.com' AND LastName LIKE '%Chen%'Neither is inherently better, but they're completely incompatible. If you're building a product that syncs with both, you need separate query-construction logic for each. Multiply that by every CRM your customers ask for, and you start to see why integration teams drown in vendor-specific code. For a comprehensive look at why this is the hardest problem in SaaS integrations, see our deep dive on schema normalization.
HubSpot-Specific Gotchas That Will Corrupt Your Data
Beyond the schema structure, HubSpot has behavioral quirks that will silently wreck your sync if you're not careful:
- Lifecycle stage only moves forward. You cannot set
lifecyclestagebackward unless you clear the existing value first. Blind last-write-wins logic will silently fail or corrupt pipeline state. - Batch upsert by email has limitations. HubSpot supports batch upsert using
emailas theidProperty, but partial upserts are not supported in that mode. If you control a stable external identifier, model it as a custom unique property and upsert against that instead. - Dynamic endpoint routing. The same logical operation can route to completely different API endpoints depending on parameters. Listing contacts might hit
/crm/v3/objects/contacts(basic list),/crm/v3/objects/contacts/search(when filters are present), or/contacts/v1/lists/{listId}/contacts/all(when querying a specific view). Your integration needs to inspect query parameters and route dynamically — another area where hardcoded, per-integration code creates a maintenance burden that scales linearly.
Do not make lifecyclestage a blind last-write-wins field. HubSpot only lets you move it forward unless you clear it first. If your app also computes lifecycle state, you need explicit ownership or a conflict resolution workflow.
Use a custom unique identifier if you can. HubSpot allows batch upsert by email, but partial upserts by email are not supported. A customer ID or external contact ID gives you cleaner semantics and fewer nasty surprises.
Handling lifecycleStage in a Bidirectional Sync
The lifecyclestage property deserves its own section because it breaks every assumption you'd make about a normal text field. HubSpot enforces forward-only movement through its API: Subscriber → Lead → MQL → SQL → Opportunity → Customer → Evangelist. If a contact is currently an "opportunity" and you try to set it to "lead" via API, the request returns a 200 but the change is silently ignored. No error. No warning. Your sync thinks it succeeded.
To move lifecycle stage backward, you need two separate API calls in sequence:
async function setLifecycleStage(contactId: string, targetStage: string) {
const current = await hubspot.getContact(contactId, ['lifecyclestage']);
const currentStage = current.properties.lifecyclestage;
if (isBackwardMove(currentStage, targetStage)) {
// Step 1: Clear the field (separate request)
await hubspot.updateContact(contactId, { lifecyclestage: '' });
// Step 2: Set the new value (must be a separate request)
await hubspot.updateContact(contactId, { lifecyclestage: targetStage });
} else {
// Forward moves work in a single call
await hubspot.updateContact(contactId, { lifecyclestage: targetStage });
}
}
const STAGE_ORDER = [
'subscriber', 'lead', 'marketingqualifiedlead',
'salesqualifiedlead', 'opportunity', 'customer', 'evangelist', 'other'
];
function isBackwardMove(current: string, target: string): boolean {
return STAGE_ORDER.indexOf(target) < STAGE_ORDER.indexOf(current);
}You can't combine the clear and set in a single batch update either. HubSpot merges multiple updates to the same contact within a batch, so the clear gets overwritten by the set. The two calls must be sequential, and you should verify the clear completed before issuing the set.
Two more things to watch for:
- Clearing lifecycle stage wipes the "Became a [stage] date" property. If your downstream reporting relies on
hs_lifecyclestage_customer_date, a clear-then-set will reset that timestamp. Preserve it manually if it matters. - Custom lifecycle stages exist. HubSpot now allows custom stages, so the fixed
STAGE_ORDERabove is only a starting point. Fetch the property definition via/crm/v3/properties/contacts/lifecyclestageto get the actual ordering for each portal.
For bidirectional sync, the safest default is to treat lifecyclestage as HubSpot-owned. If your app absolutely must set it, use explicit field ownership rules and never apply a blind last-write-wins policy.
How to Architect a Loop-Free Bidirectional Sync
Given everything above — rate limits, infinite loops, schema mismatches, dynamic routing — here's an architecture that actually works.
The Core Pattern: Event Bus + Origin Tagging + Declarative Mapping
flowchart LR
subgraph Inbound["Inbound Path"]
WH[HubSpot Webhook] --> Q1[Event Queue]
Q1 --> OC{Origin Check}
OC -->|External change| MAP1[Response Mapping]
OC -->|Echo from us| SKIP[Skip/Discard]
MAP1 --> DB[(Your Database)]
end
subgraph Outbound["Outbound Path"]
DB --> CDC[Change Detection]
CDC --> OC2{Origin Check}
OC2 -->|Internal change| MAP2[Query/Body Mapping]
OC2 -->|Echo from HubSpot| SKIP2[Skip/Discard]
MAP2 --> BATCH[Batch + Rate Limit]
BATCH --> HS[HubSpot API]
endKey components:
1. Event queue. Never process webhooks synchronously. The receiver should validate auth, do minimal schema checks, persist the raw payload, and return 2xx quickly. Transformations and downstream calls happen asynchronously. This prevents timeouts and keeps HubSpot's retry logic from over-firing.
2. Origin check. Every record in your database carries a last_sync_source field. When processing an inbound webhook, compare the changed fields against your last outbound write. If they match, it's an echo — discard it.
3. Field ownership rules. Not every field should be bidirectional. A good default:
- HubSpot-owned: owner, lifecycle stage, lead status
- App-owned: product plan, usage metrics, health score
- Conditionally bidirectional: name, phone, job title, company association
Once ownership is set, use compare-before-write on every outbound change. Fetch the current remote values, compute a fingerprint over the normalized fields, and skip the write if nothing material changed. That single habit cuts loopbacks and saves rate-limit budget.
4. Rate-limited outbound queue. A token bucket rate limiter sits in front of all outbound HubSpot calls, shared across every sync operation for that account. This prevents burst violations even when multiple sync jobs run concurrently.
5. Reconciliation cron. Webhooks are fire-and-forget. If your server is down, HubSpot retries a few times, then drops the payload. Implement a periodic reconciliation job that fetches all records where lastmodifieddate > last_sync_timestamp. This ensures dropped webhooks don't result in permanently out-of-sync data. For stronger replay and offset control at higher scale, HubSpot's newer V4 Webhooks Journal API supports journal files, offsets, and up to 3 days of historical changes.
Compare-Before-Write in Practice
Your sync journal should track more than local ID and remote ID. In practice you want: last applied inbound fingerprint, last outbound fingerprint, source system of the last accepted write, cursor or checkpoint position for backfills, per-field ownership policy, and raw vendor request IDs for debugging.
Here's what the inbound path looks like:
async function applyInboundContactChange(event: HubSpotEvent) {
const dedupeKey = `${event.subscriptionType}:${event.objectId}:${event.occurredAt}`;
if (await dedupeStore.seen(dedupeKey)) return;
const remote = await hubspot.getContact(event.objectId, CONTACT_PROPERTIES);
const unified = normalizeHubSpotContact(remote);
const fingerprint = stableHash(unified);
const prior = await syncJournal.getByRemoteId(remote.id);
if (prior?.lastAppliedFingerprint === fingerprint) return;
await appContacts.upsert(unified);
await syncJournal.save({
remoteId: remote.id,
localId: unified.id,
lastAppliedFingerprint: fingerprint,
lastSource: 'hubspot'
});
}The outbound path is the mirror image. Map the app record into HubSpot's shape, compare it to the last known remote state, write only the delta, and store the outbound fingerprint. When the echo webhook arrives, your worker sees a matching fingerprint and stops. That's the core pattern for preventing infinite loops without hardcoding vendor-specific branch logic everywhere.
Separate real-time sync from repair sync. Real-time handles fresh changes through webhooks and small writes. Repair sync sweeps updatedAt windows, replays failures, and fixes drift that every production system accumulates eventually. If you skip the repair path, you're not building sync — you're building a demo.
Embedding Origin Tags and Idempotency Markers in HubSpot
HubSpot's changeSource field in webhook payloads tells you something about where a change originated, but it's not granular enough for loop prevention on its own. A changeSource of API means any API call made the change - yours, another integration's, or a Zapier automation. You can't distinguish your app's writes from anyone else's.
The fix is a custom property origin tag. Create a single-line text property (e.g., _sync_source) on each object type you sync, and stamp it on every outbound write:
// Every outbound write includes the origin tag
await hubspot.updateContact(contactId, {
phone: '+1-415-555-0100',
_sync_source: 'myapp',
_sync_timestamp: Date.now().toString()
});When an inbound webhook arrives, your handler uses a three-layer check:
async function shouldProcessWebhook(event: HubSpotWebhookEvent): Promise<boolean> {
// Layer 1: Dedupe by eventId (handles at-least-once delivery)
if (await dedupeStore.seen(event.eventId.toString())) return false;
// Layer 2: Fast-path skip for our own marker property changes
if (event.propertyName === '_sync_source' || event.propertyName === '_sync_timestamp') {
return false;
}
// Layer 3: Fetch full record, check origin tag recency
const contact = await hubspot.getContact(
event.objectId,
['_sync_source', '_sync_timestamp']
);
const syncSource = contact.properties._sync_source;
const syncTs = parseInt(contact.properties._sync_timestamp || '0');
// If we wrote this record recently (within 30s), it's likely an echo
if (syncSource === 'myapp' && Date.now() - syncTs < 30_000) return false;
return true;
}Layer 1 prevents duplicate processing from HubSpot's retry mechanism. Layer 2 skips webhook events generated by your own origin-tag writes (since HubSpot fires a separate event per changed property). Layer 3 catches the common case where a legitimate property change like phone triggers a webhook, but your app was the one that changed it.
There's a race condition to watch for: HubSpot fires separate events for each changed property, and they can arrive in any order. If your update touches phone and _sync_source, the phone event might arrive before the _sync_source update has propagated. Your write receipt cache (tracking objectId + propertyName + propertyValue with a 60-second TTL) is the safety net for this timing gap.
Reconciliation Cadence for HubSpot
HubSpot retries failed webhook deliveries up to 10 times over roughly 24 hours, with randomized delays between attempts. After exhausting retries, the event is gone forever - HubSpot doesn't offer a built-in dead letter queue or manual replay through its standard webhook dashboard.
Your reconciliation job fills this gap by polling for changes your webhooks might have missed. Use HubSpot's lastmodifieddate filter via the Search API to find records updated since your last checkpoint.
| Scenario | Interval | Rationale |
|---|---|---|
| Normal business hours | Every 15 minutes | Catches dropped webhooks before reps notice stale data |
| Off-peak / overnight | Every 60 minutes | Lower change velocity, preserves daily API budget |
| Post-deployment | Immediate full sweep | Your endpoint was unreachable during the deploy window |
| After incident / outage | Immediate full sweep | Catch everything missed while you were down |
| Weekly full reconciliation | Weekend off-peak | Full drift detection comparing all records, ignoring lastmodifieddate |
Budget the API cost before you set your interval. For 50,000 contacts, an incremental reconciliation using Search with lastmodifieddate > checkpoint and 200 results per page needs at most ~250 paginated requests. At the Search API's 5 req/s account-wide limit, that's roughly 50 seconds of search budget. Most incremental runs will return far fewer results, but plan for end-of-quarter spikes when reps update everything at once.
HubSpot's V4 Webhooks Journal API (currently in beta) offers a pull-based alternative. Instead of receiving pushes, your app polls a journal of events using offset-based pagination with up to 3 days of history. This gives you stronger replay guarantees than standard webhooks, but since it's still in beta, treat it as a complement to - not a replacement for - your own reconciliation job.
The "Data, Not Code" Principle
The biggest architectural mistake teams make is writing custom code for each integration. HubSpot sync becomes one module. Salesforce sync becomes another. Pipedrive a third. Each with its own mapping logic, pagination handling, and error recovery.
The better pattern is a generic execution pipeline where every integration difference is captured in configuration data:
| Concern | Code-per-integration | Data-driven |
|---|---|---|
| Field mapping | Custom transformer function | JSONata/template expression |
| Query construction | Custom query builder | Declarative query mapping |
| Endpoint routing | if/else branches | Expression-based resource resolution |
| Pagination | Custom cursor handling | Declarative pagination config |
| Error handling | Per-API error parsing | Normalized error mapping |
With a data-driven approach, adding a new CRM means adding configuration rows, not code. The execution engine stays the same.
How Truto's Architecture Handles HubSpot Without Custom Code
This is where Truto's approach becomes relevant — not as a silver bullet, but as a concrete implementation of the "data, not code" pattern described above.
Truto's unified API handles HubSpot (and Salesforce, and Pipedrive, and dozens of other CRMs) through a single generic execution pipeline. Every difference between HubSpot and other CRMs is captured in declarative mapping configurations — not in integration-specific code branches.
What This Looks Like in Practice
A caller makes a single request:
GET /unified/crm/contacts?integrated_account_id=abc123&limit=10
The caller doesn't know or care whether abc123 is a HubSpot account or a Salesforce account. Under the hood, Truto's pipeline reads the mapping configuration for the connected CRM and handles everything: constructing filterGroups for HubSpot, building SOQL for Salesforce, routing to the correct endpoint, extracting and normalizing fields, and producing a unified response.
Endpoint routing is handled declaratively:
resource:
resources:
- contacts # Default: list all contacts
- contacts-search # When filter params are present
expression: >
$firstNonEmpty(rawQuery.first_name, rawQuery.last_name) ? 'contacts-search'
: 'contacts'Query translation from your unified filters into HubSpot's filterGroups arrays happens through data configuration, not custom code:
request_body_mapping: >-
rawQuery.{
"filterGroups": $firstNonEmpty(first_name, last_name, email_addresses)
? [{
"filters":[
first_name ? { "propertyName": "firstname", "operator": "CONTAINS_TOKEN", "value": first_name },
email_addresses ? { "propertyName": "email", "operator": "IN",
"values": [$firstNonEmpty(email_addresses.email, email_addresses)] }
]
}],
"query": search_term
}And response normalization uses JSONata to flatten HubSpot's nested structure into a canonical model:
{
"id": response.id,
"first_name": response.properties.firstname,
"last_name": response.properties.lastname,
"email_addresses": [
response.properties.email
? { "email": response.properties.email, "is_primary": true },
response.properties.hs_additional_emails
? response.properties.hs_additional_emails.$split(";").{ "email": $ }
],
"phone_numbers": [
response.properties.phone
? { "number": response.properties.phone, "type": "phone" },
response.properties.mobilephone
? { "number": response.properties.mobilephone, "type": "mobile" }
],
"created_at": response.createdAt,
"updated_at": response.updatedAt
}The equivalent mapping for Salesforce is a different JSONata expression, but the engine that evaluates it is identical. Zero branching on integration name. Zero integration-specific code. The remote_data field in Truto's response preserves the original HubSpot response, so you never lose access to provider-specific data when you need it.
| HubSpot Challenge | How Truto Handles It |
|---|---|
Nested properties object |
JSONata response mapping extracts and flattens fields |
hs_additional_emails as semicolon string |
Mapping expression splits and normalizes to array |
filterGroups search syntax |
Declarative query/body mapping constructs the correct payload |
| Dynamic endpoint routing | Expression-based resource resolution picks the right endpoint |
| Rate limiting | Built-in rate limiter respects per-account burst and daily limits |
| Webhook echoes and retries | Generic sync journal and dedupe logic |
For more on how this zero-code architecture works under the hood, see Look Ma, No Code! Why Truto's Zero-Code Architecture Wins.
Honest Trade-offs
A unified API is not the right answer for every scenario:
- Deeply custom HubSpot workflows: If you need to orchestrate multi-step sequences using HubSpot-specific features (enrollment triggers, custom code actions), a unified API won't replace that. You'll need direct HubSpot API access.
- Real-time latency under 500ms: A unified API adds a translation layer. If your use case demands sub-second latency for every request, the overhead matters. For most sync workloads — where "real-time" means seconds to minutes — it's a non-issue.
- Enterprise escape hatches: Enterprise customers will ask for HubSpot-only objects, unusual custom properties, or one-off workflows that don't fit a common model cleanly. A good platform needs a pass-through or proxy path for that long tail. If a vendor tells you the common model covers everything, be skeptical. Real integrations are messier than the brochure.
The strongest use case for a unified API is when you need to support multiple CRMs simultaneously and your team's time is better spent on product features than on per-integration code. As we discussed in our guide to building native CRM integrations without draining engineering, a team of two engineers can ship HubSpot, Salesforce, and Pipedrive integrations in a week instead of a quarter.
Bidirectional HubSpot Sync Troubleshooting Checklist
When your sync goes sideways, work through this list before diving into code:
Loop detection:
- Are webhook events for your own writes being suppressed? Check your
_sync_sourcetag or write receipt cache. - Is
eventIdbeing tracked for idempotency? HubSpot delivers at least once - duplicates are expected. - Are you seeing
attemptNumber > 0in payloads? Your endpoint is returning errors or exceeding the 5-second response window. - Are you getting a flood of
propertyChangeevents after creating a record? HubSpot fires individual property change events for each field set during creation.
Data integrity:
- Is
lifecyclestagesilently failing to update? You're likely trying to move it backward without clearing first. The API returns 200 anyway. - Are batch upserts by email producing partial failures? Switch to a custom unique identifier as
idProperty. - Are you seeing stale data in Search results? The Search index lags. Read the object directly via GET after writes.
- Are
Became a [stage] datetimestamps disappearing? Clearinglifecyclestagewipes the corresponding date property.
Rate limits:
- Are you hitting 429s? Check
X-HubSpot-RateLimit-Remainingheaders on non-search endpoints. - Are multiple integrations sharing the same HubSpot account? The daily quota is shared across all private apps; burst limits are per-app but search limits are account-wide.
- Are Search API calls being throttled separately? The 5 req/s cap is account-wide, not per-app.
- After a 429, are you hitting a 1-minute block? That's the escalation - 10 requests returning 429 within one second triggers a full-minute lockout.
Webhook delivery:
- Is your endpoint responding within 5 seconds? HubSpot will timeout, retry, and potentially create duplicate processing.
- Is V3 signature validation working? It requires
requestMethod + requestUri + requestBody + timestamp, HMAC-SHA256'd with your client secret, then Base64-encoded. The URI must match the exact target URL you configured, including any query parameters. - Did you recently change your webhook URL? HubSpot caches webhook configuration for up to 5 minutes. Keep the old endpoint alive during the transition.
- Are you handling the payload as an array? Each POST contains an array of events, not a single event.
Reconciliation:
- Is your reconciliation job running on schedule? Webhooks alone are not a guaranteed delivery mechanism.
- After a deployment or outage, did you trigger a full sweep? HubSpot stops retrying after ~24 hours.
- Is the
lastmodifieddatecheckpoint stored per-account? Don't share cursors across different HubSpot portals.
What to Ship This Week
If you're staring down a bidirectional HubSpot integration, ship in this order:
-
Define your canonical model and field ownership matrix. Decide which fields are HubSpot-owned, app-owned, and conditionally bidirectional before you write any sync code.
-
Implement webhook ingestion with signature validation, queueing, and dedupe. Subscribe to
contact.propertyChangeanddeal.propertyChangeevents. ValidateX-HubSpot-Signature-V3. Acknowledge fast, process later. -
Build the outbound write path with batching and compare-before-write. Use HubSpot's batch upsert endpoints. A sync that processes 1,000 contacts should make ~10 API calls, not 1,000.
-
Add a cursor-based backfill and reconciliation job. Run it every 15 minutes as a safety net to catch anything webhooks miss. Store the
aftercursor and page forward in checkpointed windows. -
Abstract your mapping layer early. Even if you only support HubSpot today, write your field mappings as data (JSON config, JSONata expressions, or similar). When product asks you to add Salesforce next quarter, you'll add configuration instead of rewriting code.
-
Evaluate a unified API if you're supporting 3+ CRMs. The engineering cost of maintaining custom sync code for each CRM scales linearly. A unified API like Truto flattens that curve by handling vendor-specific translation in a shared execution pipeline.
That sequence is boring on purpose. Fancy workflow automation can wait. First make the sync trustworthy.
The difference between a HubSpot integration that works and one that works at scale is the architecture underneath it. Get the loop prevention, rate limit handling, and mapping layer right, and everything else follows.
FAQ
- What are HubSpot's current API rate limits?
- HubSpot OAuth public apps are limited to 110 requests every 10 seconds per account. Private apps on Professional or Enterprise plans get 190 requests per 10 seconds and up to 1 million requests per day. The CRM Search API has a separate, stricter limit of 5 requests per second shared at the account level across all integrations. Search responses also lack standard rate-limit headers, so you need your own pacing logic.
- How do you prevent infinite loops in a bidirectional HubSpot sync?
- Use origin tagging to stamp every outbound write with a source identifier. When a webhook arrives, check whether the change originated from your system and skip it if so. Combine this with a compare-before-write fingerprint (hashing incoming payloads against stored state) and idempotent upserts to eliminate echo writes without hardcoding vendor-specific logic.
- Can HubSpot webhooks replace polling completely?
- No. Webhooks are the fast path, not the only correctness path. HubSpot batches up to 100 events per request, retries failed notifications up to 10 times, and delivery is at-least-once. You should always pair webhooks with a periodic reconciliation job to catch dropped or missed events and fix data drift.
- Why is HubSpot's data mapping harder than other CRMs?
- HubSpot nests all fields inside a properties object, uses semicolon-delimited strings for multi-value fields like additional emails, requires filterGroups arrays for search queries, and restricts lifecycle stage to forward-only movement. Other CRMs like Salesforce use flat PascalCase fields and SQL-like query languages, making the two schemas completely incompatible.
- When does a unified API make sense for CRM sync?
- When you need the same sync behavior across multiple CRMs, want a common customer model for your product code, and your team's time is better spent on features than per-integration maintenance. If HubSpot is the only CRM you will ever support and you need deep HubSpot-only features everywhere, a custom build can still be rational.