HubSpot to internal DB sync: pitfalls we hit
Five painful lessons from building bi-directional HubSpot to Postgres syncs for SMB clients.
Two-way sync is harder than one-way
Pulling HubSpot into Postgres is easy. Pushing changes back without creating an infinite loop is hard. Here are the five things we got wrong on the first sync we built — so you do not have to.
1. The webhook → write → webhook loop
You receive a HubSpot webhook, you update Postgres. A trigger fires that updates HubSpot. HubSpot fires another webhook. Infinite loop.
Fix: tag every write you make to HubSpot with a custom property like internal_sync_at. When a webhook arrives, if the internal_sync_at is within the last 60 seconds, ignore it.
2. The conflict resolution problem
A user updates a contact in HubSpot. Your support tool updates the same contact two seconds later. Which one wins?
There is no universal answer. We default to "last write wins by timestamp" but expose this as a config the client can change per field. Email might be HubSpot-wins, lifecycle stage might be internal-wins.
Make this explicit. Do not hide it in code.
3. The deleted record problem
HubSpot's webhook for deletes is unreliable across edge cases (merges, GDPR deletes). We poll for deleted records every 6 hours as a backstop. Yes, it is ugly. Yes, it has saved us.
4. The rate limit
HubSpot's API rate limits are per-portal and they bite at scale. Use the batch endpoints (/crm/v3/objects/contacts/batch/read) for bulk syncs and respect the X-HubSpot-RateLimit-Remaining header.
If you are syncing more than 10k contacts, plan the initial backfill carefully — do it in chunks, off-hours, and instrument the retry path.
5. The custom property name change
A user renames a custom property in HubSpot. Your code references the old internal name. The sync silently breaks.
Fix: never reference custom property internal names in code. Store the mapping in your database, and reload it on a schedule. When the mapping changes, log it loudly.
What we always ship
Every HubSpot sync we build has:
- Idempotent webhook handlers
- A scheduled reconciliation job
- A "force resync this contact" admin endpoint
- Field-level mapping in config, not code
- An audit log of every change with source and timestamp
These take an extra week to build. They save months of "why did this contact change?" support tickets.