How to Architect a Bidirectional HubSpot Sync (Without Infinite Loops)
A definitive engineering guide to building a bidirectional HubSpot sync. Learn how to handle API rate limits, dynamic schema mapping, and infinite webhook loops.
You are reading this because your bidirectional HubSpot integration is failing in production. A one-way data pipeline—pushing leads from a marketing site into a CRM—is a solved problem. But the moment you turn on the return path, everything breaks. You hit HTTP 429 Too Many Requests errors mid-sync, your webhooks create infinite update loops, and your engineering team spends cycles debugging custom schema mappings instead of building core product features.
Stale CRM data is a revenue problem, not just an engineering annoyance. B2B contact data decays at roughly 2.1% per month, compounding to about 22.5% annually. This means nearly a quarter of your database goes stale within a year. The financial impact is severe. Gartner research consistently cites $12.9 million as the average annual financial impact of poor data quality on organizations. Furthermore, Salesforce's State of Sales report reveals that sales reps spend only 28% of their time actually selling. The rest of their week is consumed by administrative work and fighting bad data. When your product's usage signals and lead scores don't land in HubSpot before the next rep picks up the phone, that is revenue leaking.
Whether you are building a two-way data sync between your app and HubSpot from scratch or trying to fix a broken one, you need an architectural pattern that handles every failure mode. This guide is a runnable walkthrough on how to sync customer data bidirectionally between your app and HubSpot. We break down the hardest problems in bidirectional CRM syncs, complete with real rate limiters, a reconciliation worker that breaks infinite loops with fingerprinting, and an honest look at which parts a unified API actually removes versus what you still have to build. For more on these specific failure modes, see our bidirectional HubSpot sync tutorial on rate limits and reconciliation.
The Architectural Challenge of Bidirectional HubSpot Sync
Bidirectional sync is a distributed systems problem where two independent databases (your application and HubSpot) must maintain eventual consistency without a centralized locking mechanism. Both your app and HubSpot are authoritative writers. Either side can create, update, or delete a record, and those changes must propagate to the other side without duplication, data loss, or infinite loops.
Because you do not control HubSpot's internal database, you cannot use standard database transactions. You are forced to rely on webhooks for incoming changes and HTTP REST calls for outgoing changes. This introduces five major failure modes:
- Rolling Burst Rate Limits: HubSpot enforces strict rolling burst limits that trigger mid-batch and do not respect your standard queue depth.
- Infinite Webhook Echoes: An update you wrote to HubSpot fires a webhook back to your app, which your consumer treats as an external change, triggering another write.
- Dynamic Property Schemas: HubSpot schemas vary per portal, per object type, and per customer configuration. Hardcoded mappings will fail.
- Out-of-Order Delivery: Webhooks can arrive out of order, meaning a delete might arrive before the create event it depends on.
- OAuth Token Lifecycles: Access tokens can expire silently during long-running background syncs, causing batches to fail midway.
Each of these failure modes has a well-understood fix. Wiring them together into one service that stays correct under load is the actual work. For more context on how this fits into a broader CRM strategy, review our guide on CRM Integration Implementation Recipes: Salesforce & HubSpot Architecture.
Handling HubSpot API Rate Limits and 429 Errors
HubSpot's public API enforces rolling 10-second burst limits. Private apps get 100 requests per 10 seconds on Free/Starter tiers and 190 requests per 10 seconds on Pro/Enterprise tiers, in addition to daily caps. Blow past the burst window and you get an HTTP 429 Too Many Requests response with a Retry-After header. Blow past it repeatedly and HubSpot will start penalizing your app-level quota.
The first mistake most engineering teams make is treating rate limits as an exceptional case. They aren't. During any real historical backfill or webhook storm, you will hit 429s. If you implement a naive retry loop with a fixed 1-second delay, you will still fail. The correct default is implementing a queueing system or a token-bucket limiter in front of every HubSpot call, not a try/catch block around them.
Strategy 1: The Token-Bucket Limiter
For synchronous operations or lightweight workers, a token-bucket limiter that reads HubSpot's response headers and adapts is the best approach. Here is a minimal limiter using the bottleneck library:
import Bottleneck from "bottleneck";
// Pro/Enterprise tier: 190 requests per 10 seconds
// We size the reservoir slightly lower to leave headroom.
const limiter = new Bottleneck({
reservoir: 170,
reservoirRefreshAmount: 170,
reservoirRefreshInterval: 10_000,
maxConcurrent: 20,
minTime: 50,
});
async function hubspotFetch(path: string, init: RequestInit = {}) {
return limiter.schedule(async () => {
const res = await fetch(`https://api.hubapi.com${path}`, {
...init,
headers: {
Authorization: `Bearer ${await getAccessToken()}`,
"Content-Type": "application/json",
...(init.headers || {}),
},
});
if (res.status === 429) {
// Retry-After is authoritative. Do not invent your own backoff curve.
const retryAfter = Number(res.headers.get("retry-after") ?? 10);
console.warn(`[Rate Limit Hit] Pausing execution for ${retryAfter}s`);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
throw new Error("RATE_LIMITED"); // Let Bottleneck retry the operation
}
if (!res.ok) throw new Error(`HubSpot ${res.status}: ${await res.text()}`);
return res.json();
});
}A few architectural rules worth calling out here:
- Reservoir sizing must live one tier below your actual quota. If you are on the Pro tier (190/10s), configure the limiter for 170 or 180. Your webhook consumer, backfill worker, and interactive UI requests all share that same quota. Leave headroom.
Retry-Afteris authoritative. Do not invent your own exponential backoff curve when HubSpot tells you exactly how many seconds to wait.- Isolate per portal. If you are a multi-tenant SaaS application, each customer's HubSpot portal has its own quota. One shared limiter across all tenants means a single noisy customer will starve everyone else.
Strategy 2: Background Queueing with Axios Interceptors
The token-bucket approach works well, but holding HTTP connections open during a setTimeout can exhaust your server's connection pool in high-volume enterprise environments. For heavy workloads, you must wrap your outbound HTTP requests in an interceptor that throws a specific error, allowing a durable background queue to delay the job.
import axios from 'axios';
const hubspotClient = axios.create({
baseURL: 'https://api.hubapi.com',
headers: {
'Authorization': `Bearer ${process.env.HUBSPOT_TOKEN}`
}
});
hubspotClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response && error.response.status === 429) {
// Check for standardized IETF headers (e.g., if routed through a unified API)
// or fallback to HubSpot's native retry-after header.
const resetTimeStr = error.response.headers['ratelimit-reset'] || error.response.headers['retry-after'];
let waitTimeMs = 10000; // Default 10 seconds for HubSpot's rolling window
if (resetTimeStr) {
const resetTime = parseInt(resetTimeStr, 10);
waitTimeMs = resetTime * 1000;
}
// Instead of waiting in-memory, we reject with a custom error
// so our background worker can delay the job and free up the execution thread.
const rateLimitError = new Error('RATE_LIMIT_EXCEEDED');
(rateLimitError as any).delayMs = waitTimeMs;
return Promise.reject(rateLimitError);
}
return Promise.reject(error);
}
);For a broader treatment of scaling this queueing architecture across many providers, read our deep dive on Handling API Rate Limits and Webhooks from Dozens of Integrations.
Preventing Infinite Webhook Loops with Fingerprinting
This is where most bidirectional syncs die. Infinite Webhook Loops occur when an automated update from System A to System B triggers a webhook back to System A, which System A misinterprets as a new user action, causing it to push another update to System B.
The pattern is entirely predictable:
sequenceDiagram participant App as Your App participant HubSpot as HubSpot API App->>HubSpot: 1. Update Contact (HTTP PATCH) HubSpot-->>App: 2. 200 OK HubSpot->>App: 3. Webhook (contact.propertyChange) App->>HubSpot: 4. Update Contact (Echo) HubSpot->>App: 5. Webhook (Echo)
HubSpot cannot distinguish between a contact update made by a sales rep clicking a button in the UI and an update made by your API key. Both trigger the exact same webhook. The naive fix—"just check if the value is the same"—fails because timestamps, whitespace normalization, and downstream CRM enrichment workflows cause spurious differences.
The correct pattern is content fingerprinting combined with a short-lived write ledger.
The Fingerprinting Pattern
Before your application writes to HubSpot, compute a stable SHA-256 hash of the normalized payload. Store (objectId, fingerprint, writtenAt) in a fast, in-memory cache or a small ledger table with a Time-To-Live (TTL) of about 5 minutes.
When a webhook arrives from HubSpot, your application generates a fingerprint of the incoming webhook payload. It then checks the ledger. If the hash exists, it means your application originated the change. The webhook is an echo, and you drop it safely. If the hash does not exist, the change originated in HubSpot (e.g., a rep updated a field manually), and you process it.
import { createHash } from "crypto";
// 1. Generate a deterministic hash of the payload
function fingerprint(payload: Record<string, unknown>): string {
// Sort keys for deterministic ordering and drop volatile fields
const normalized = Object.keys(payload)
.filter((k) => !VOLATILE_FIELDS.has(k))
.sort()
.reduce((acc, k) => ({ ...acc, [k]: payload[k] }), {});
return createHash("sha256")
.update(JSON.stringify(normalized))
.digest("hex");
}
// Fields that HubSpot automatically alters; ignore these in hashes
const VOLATILE_FIELDS = new Set([
"hs_lastmodifieddate",
"lastmodifieddate",
"updatedAt",
]);
// 2. Register the outbound update in your ledger
async function writeContactToHubspot(id: string, payload: any) {
const fp = fingerprint(payload);
// Store the hash with a 5-minute TTL (300 seconds)
await ledgerConnection.set(`hs:contact:${id}:${fp}`, "1", "EX", 300);
return hubspotFetch(`/crm/v3/objects/contacts/${id}`, {
method: "PATCH",
body: JSON.stringify({ properties: payload }),
});
}
// 3. Check incoming webhooks against the ledger
async function onHubspotWebhook(event: any) {
const incoming = await fetchContactSnapshot(event.objectId);
const fp = fingerprint(incoming);
const isEcho = await ledgerConnection.exists(`hs:contact:${event.objectId}:${fp}`);
if (isEcho === 1) {
console.log(`[Webhook] Dropping echo for contact ${event.objectId}`);
return; // This is our own write
}
console.log(`[Webhook] Processing external update for contact ${event.objectId}`);
await applyToOurApp(event.objectId, incoming);
}Why the ledger, not just a boolean flag?
A per-object "sync in progress" boolean flag races the moment two writes overlap. The ledger tracks the content you wrote, not the mere fact that you wrote. If HubSpot delivers the webhook 30 seconds late, the fingerprint still matches. If a real user makes a different change in the meantime, that change produces a different fingerprint and gets processed normally.
Edge cases you will actually hit
- Property change webhooks fire per property, not per object. A single PATCH updating five properties triggers five separate webhooks from HubSpot. You must debounce by object ID for 2-5 seconds before pulling the snapshot.
- Deletes do not fingerprint. Track deletions with an explicit
(objectId, deletedAt)ledger entry instead of payload hashing. - Association changes bypass property webhooks. You must subscribe to
contact.creation,contact.deletion,contact.propertyChange, andcontact.associationChangeseparately in your HubSpot developer app settings.
For a more detailed pattern library on this specific problem, see The Architect's Guide to Bi-Directional API Sync (Without Infinite Loops).
Normalizing Custom Properties and Schema Mapping
Schema Mapping is the process of translating the strict, relational data model of your application into the dynamic, key-value properties structure used by CRMs like HubSpot.
HubSpot does not have a static schema for Contacts, Companies, or Deals. Every HubSpot portal has a different set of custom properties. If your application has a subscription_tier column in your relational database, you cannot hardcode a sync to a HubSpot property named subscription_tier. The HubSpot admin might have named it sub_tier, Subscription Level, or plan_tier__c.
The pattern that scales is a declarative field map per tenant, resolved at sync time.
type FieldMap = {
ourField: string;
hubspotProperty: string;
direction: "in" | "out" | "both";
transform?: {
toHubspot?: (v: unknown) => unknown;
fromHubspot?: (v: unknown) => unknown;
};
};
const contactFieldMap: FieldMap[] = [
{ ourField: "email", hubspotProperty: "email", direction: "both" },
{
ourField: "planTier",
hubspotProperty: "plan_tier__c",
direction: "out",
transform: {
toHubspot: (v) => String(v).toLowerCase(),
},
},
{
ourField: "signupAt",
hubspotProperty: "signup_date",
direction: "out",
transform: {
// HubSpot date properties must be Unix milliseconds at midnight UTC
toHubspot: (v) => {
const d = new Date(v as string);
d.setUTCHours(0, 0, 0, 0);
return d.getTime();
},
},
},
];You store this field map per tenant in your database as a JSON object and expose it through a customer-facing settings UI. Load it at sync time. When preparing to send data to HubSpot, you dynamically construct the properties payload based on this configuration.
One more best practice: pull the property schema from /crm/v3/properties/contacts on connection and cache it with a short TTL. This lets you validate that plan_tier__c actually exists and is a picklist with the expected options before you start writing bad data and generating API errors.
Building the Sync: A Step-by-Step Code Tutorial
Let us tie these concepts together into a runnable implementation. We will build a service that handles outbound updates via an outbox pattern, processes incoming HubSpot webhooks securely, filters out echoes, and runs a nightly reconciliation job.
1. Architecture Overview
flowchart LR
A["Your App<br>Change Event"] --> B["Outbox Queue"]
B --> C["Rate Limiter / Background Queue"]
C --> D["HubSpot API"]
D -.webhook.-> E["Webhook Endpoint"]
E --> F["Fingerprint Check"]
F -->|echo| G["Drop"]
F -->|real change| H["Apply to Your App"]
C --> I["Write Ledger<br>(TTL 5m)"]
F --> I2. The Outbound Path (Outbox Pattern)
Every change in your app should write to an outbox table in the same database transaction as the business write. A worker drains the outbox, applies the declarative field map, records the fingerprint, and calls HubSpot.
async function drainOutbox() {
// Fetch pending jobs meant for HubSpot
const jobs = await db.outbox.claimBatch({ limit: 50, forProvider: "hubspot" });
for (const job of jobs) {
try {
// 1. Map internal data to HubSpot shape
const payload = applyFieldMap(job.payload, contactFieldMap, "toHubspot");
// 2. Write to HubSpot and register fingerprint (from previous section)
await writeContactToHubspot(job.externalId, payload);
// 3. Mark complete
await db.outbox.markComplete(job.id);
} catch (err: any) {
if (err.message === "RATE_LIMIT_EXCEEDED" || err.message === "RATE_LIMITED") {
// Requeue with delay based on the Retry-After header
const delay = err.delayMs || 10000;
await db.outbox.requeue(job.id, { delayMs: delay });
} else {
await db.outbox.markFailed(job.id, err.message);
}
}
}
}The outbox pattern gives you two properties you cannot get from direct API calls: atomicity with your business writes, and replayability if the sync worker crashes mid-batch.
3. The Inbound Path (Webhook Verification)
When HubSpot sends a webhook, you must verify its cryptographic signature, debounce it, check for echoes, and apply the update.
import express from "express";
import { createHmac, timingSafeEqual } from "crypto";
const app = express();
// Use raw body for signature verification
app.use('/webhooks/hubspot', express.raw({ type: "application/json" }));
app.post("/webhooks/hubspot", async (req, res) => {
const signature = req.header("x-hubspot-signature-v3") || "";
const timestamp = req.header("x-hubspot-request-timestamp") || "";
// Reconstruct the raw payload string
const raw = `POST${process.env.WEBHOOK_URL}${req.body}${timestamp}`;
const expected = createHmac("sha256", process.env.HUBSPOT_CLIENT_SECRET!)
.update(raw)
.digest("base64");
// Prevent timing attacks using timingSafeEqual
if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Unauthorized');
}
const events = JSON.parse(req.body.toString());
// Acknowledge receipt immediately to prevent HubSpot from retrying
res.status(204).end();
for (const event of events) {
// Debounce by objectId to handle HubSpot's per-property webhook firing
await debouncer.enqueue(event.objectId, () => processEvent(event));
}
});4. Reconciliation for Missed Events
Webhooks get lost. Rate limits cause dropped writes. An integration without a reconciliation loop is a ticking time bomb. Run a nightly reconciliation job that lists contacts modified in the last 24 hours (using the hs_lastmodifieddate filter) and compares fingerprints against your local copy. Anything that differs gets re-synced.
async function reconcile() {
const since = Date.now() - 24 * 60 * 60 * 1000; // Last 24 hours
let after: string | undefined;
do {
const page = await hubspotFetch("/crm/v3/objects/contacts/search", {
method: "POST",
body: JSON.stringify({
filterGroups: [{
filters: [{ propertyName: "hs_lastmodifieddate", operator: "GTE", value: since }]
}],
limit: 100,
after,
}),
});
for (const contact of page.results) {
const local = await db.contacts.findByExternalId(contact.id);
const fpRemote = fingerprint(contact.properties);
const fpLocal = local ? fingerprint(toHubspotShape(local)) : null;
if (fpRemote !== fpLocal) {
console.log(`[Reconciliation] Drift detected for contact ${contact.id}`);
// Force a sync event
await processEvent({ objectId: contact.id, subscriptionType: "contact.propertyChange" });
}
}
after = page.paging?.next?.after;
} while (after);
}That is the complete architecture: outbox for outbound writes, verified webhooks for inbound writes, a fingerprint ledger for loop prevention, and nightly reconciliation for durability.
Why Unified APIs Replace Custom Integration Code
Everything above is real, tested code. It is also code that has absolutely nothing to do with your core product. Building a bidirectional sync from scratch requires you to become an expert in HubSpot's specific quirks.
You have to manage the OAuth token lifecycle, refreshing access tokens shortly before they expire. You have to parse HubSpot's specific rate limit headers. You have to map flat data to nested properties objects. When you need to add Salesforce next month, you have to write all of this logic again, because Salesforce uses entirely different rate limit headers, different webhook structures, and different schema mapping rules.
A unified API layer replaces this boilerplate:
| Integration Concern | DIY Approach | Unified API Approach |
|---|---|---|
| OAuth token refresh | Manual cron jobs per provider | Refreshed automatically shortly before expiry |
| Rate limit headers | Parse custom headers per provider | Normalized to standardized IETF ratelimit-* headers |
| Property schema | Fetch and cache per portal manually | Unified data model with pass-through for custom fields |
| Webhook signatures | Verify custom HMACs per provider | Normalized, authenticated event envelope |
| Reconciliation | Build custom search queries per provider | Standardized bulk read endpoints |
It is worth being honest about the trade-offs: a unified API standardizes the common 80% of a CRM data model. The last 20%—deeply custom objects, portal-specific workflows, or HubSpot-only features like sequences—still requires provider-specific work. A well-designed unified layer exposes a pass-through mechanism for those cases so you are not blocked.
On rate limits specifically: Platforms like Truto normalize upstream rate limit information into the IETF-standard ratelimit-limit, ratelimit-remaining, and ratelimit-reset headers on every response. When HubSpot returns a 429, Truto passes that error through to your caller with those normalized headers. Your app still owns the retry and backoff decision. That is a deliberate design choice: automatic retries at the platform layer hide backpressure signals your app needs to see to scale properly.
Where to Go From Here
If you are building this from scratch today, follow this order of operations to save the most engineering time:
- Start with the outbox pattern. Even if you never adopt a unified API, atomic outbound writes are the single highest-leverage architectural change you can make.
- Add fingerprint-based loop prevention before you turn on webhooks. Retrofitting this after production database incidents is incredibly painful.
- Build the field map as configuration, not code. Every hardcoded property name is a future customer support ticket.
- Schedule reconciliation from day one. Webhooks will lose events. Reconciliation is your safety net.
- Evaluate whether the CRM-specific work is your differentiator. If you sell to companies that use five different CRMs, writing all this boilerplate five times is a poor use of engineering headcount.
The hard parts of bidirectional sync are the same across every CRM. The value your product delivers on top of clean CRM data is what your customers actually pay for. Spend your engineering time accordingly.
FAQ
- How do I prevent infinite loops in a bidirectional HubSpot sync?
- Compute a content fingerprint of every payload you write to HubSpot and store it in a short-lived ledger (like Redis) with a 5-minute TTL. When a webhook arrives, generate a hash of the incoming snapshot and drop the webhook if the hash matches a recent write.
- What are HubSpot's API rate limits for private apps?
- HubSpot enforces a rolling 10-second burst limit. Private apps are typically capped at 100 requests per 10 seconds on Free/Starter tiers, and 190 requests per 10 seconds on Pro/Enterprise tiers. You must use a token-bucket limiter or queueing system to respect these limits.
- How should I handle custom properties in a HubSpot integration?
- Do not hardcode field names. Store a declarative field map per tenant that maps your app's fields to HubSpot property internal names, with optional transform functions for type coercion. Fetch the property schema from the HubSpot API on connection to validate mappings.
- Why do I need a reconciliation job if I am using webhooks?
- HubSpot webhooks can be delayed, arrive out of order, or occasionally be lost entirely. You must run a nightly reconciliation job that queries records modified in the last 24 hours using the hs_lastmodifieddate filter, compares fingerprints against your local copy, and re-syncs differences.
- Does a unified API remove the need to handle HubSpot rate limits?
- No. A unified API normalizes rate limit information into standard IETF headers (like ratelimit-reset) so you write one backoff function instead of one per provider, but your application still owns the queueing and retry decisions to handle backpressure properly.