The Architect's Guide to Bi-Directional API Sync (Without Infinite Loops)
Prevent infinite loops in bi-directional API syncs. Learn proven architectural patterns for webhook echo prevention, delta sync strategies, and loop-free contention resolution.
Bi-directional sync between your product and a third-party API like Salesforce or HubSpot sounds simple on a whiteboard. System A writes to System B. System B writes back to System A. Both stay in sync. In production, it is anything but simple. The moment both systems can write to the same record, you open the door to infinite loops — phantom updates bouncing between systems, draining API quotas, polluting audit logs, and silently corrupting customer data.
These are what the Valence team calls "vampire records": data entries that bounce back and forth indefinitely, feeding on your API limits without ever dying. The financial impact is not hypothetical. Gartner estimates that poor data quality costs organizations an average of $12.9 million every year in wasted resources and lost opportunities. A Validity survey of over 1,250 companies found that 44% estimate they lose more than 10% in annual revenue from low-quality CRM data. When your bidirectional sync is the source of that bad data, the damage is immediate, visible, and strictly financial.
Architecting a bi-directional integration that prevents infinite loops without requiring custom code for every third-party application is one of the hardest engineering challenges in B2B SaaS. This guide breaks down exactly why these loops occur, why the traditional workarounds fail at scale, and how to build a loop-free architecture that works across dozens of integrations.
The "Vampire Record" Problem: Why Bi-Directional Syncs Loop
The mechanics of an infinite loop are deceptively simple. Consider a standard bi-directional sync between HubSpot and your internal database:
- A sales rep updates a contact's phone number in HubSpot.
- HubSpot fires a
contact.updatedwebhook to your application. - Your application processes the payload and updates the corresponding record in your database.
- Your application logic detects the internal change and fires a sync job to keep all connected systems current.
- Your system sends a
PATCHrequest back to HubSpot with the updated contact. - HubSpot registers this
PATCHas a new mutation and fires anothercontact.updatedwebhook back to your application. - Go to step 3. Repeat forever.
sequenceDiagram
participant Rep as Sales Rep
participant HS as HubSpot
participant You as Your App
Rep->>HS: Updates contact
HS->>You: Webhook: contact.updated
You->>You: Update internal DB
You->>HS: PATCH /contacts/:id (sync back)
HS->>You: Webhook: contact.updated (echo!)
You->>You: Update internal DB
You->>HS: PATCH /contacts/:id (sync back)
Note over HS, You: Infinite loop establishedThat sounds obvious on paper. In production, the loop hides behind boring field updates like owner_id, lifecycle_stage, status, or last_activity_at. Loops typically start for four reasons:
- You key off
updated_atonly. Delta sync without origin metadata will happily treat your own write as a fresh inbound change. - You cannot distinguish human writes from machine writes. Not every API exposes reliable
last_modified_bymetadata in webhook payloads. Some apps collapse all automated edits under one service account. Others do not surface actor metadata at all. - Your provider retries or reorders events. Stripe does not guarantee event ordering and can send duplicates. HubSpot retries failed webhooks up to 10 times and treats responses slower than 5 seconds as failures. Slack expects a valid response in 3 seconds and retries failed deliveries up to 3 times.
- Your write path enriches data and accidentally emits a secondary change. Skinny webhook payloads often force a read-after-webhook fetch, which is good for data quality but easy to wire incorrectly.
The damage compounds fast. Every bounce means one webhook delivery, one lookup, one upsert, and potentially one retry. In Salesforce, Enterprise Edition organizations are capped at a baseline of 100,000 daily API requests, plus 1,000 per user license. An infinite loop triggered by a bulk update of just 500 contacts can exhaust a customer's entire daily Salesforce API quota in minutes. When that quota is breached, every other integration in the org stops working and the customer's operations grind to a halt.
If you are building integrations for enterprise clients, you must architect defensive mechanisms against this exact scenario. For more context on handling CRM-specific rate limits, see our guide on Architecting Real-Time CRM Syncs for Enterprise.
3 Traditional Ways to Prevent Infinite Loops (And Why They Break)
Most teams discover the loop problem after their first production incident and reach for one of three workarounds. Each works in isolation. None scales to 20+ integrations.
1. Dedicated Integration Users
The most commonly recommended fix. Create a service account (e.g., integration-bot@yourcompany.com) in each connected app. Filter your inbound triggers to ignore any update made by that user. Workato explicitly recommends this pattern, and MuleSoft's bidirectional Salesforce templates expose integration.user.id settings for the same reason.
Why it breaks:
- You need a dedicated user license per connected app, per customer. At $25–$75/month per Salesforce user license, this gets expensive fast across a customer base.
- Not every SaaS API exposes the
updated_byfield in webhook payloads or change events. Some only give you the record data, not who changed it. - Enterprise IT departments frequently reject this requirement during security reviews, preferring OAuth applications with scoped permissions over provisioning synthetic human accounts.
- The exact implementation steps vary per provider, meaning you are writing bespoke loop-prevention logic for every single integration.
2. Payload Hashing and Change Detection
Before writing an update to the target system, compute a hash of the payload. Store it. When you receive a webhook from the target, compare the incoming payload's hash to the stored one. If they match, the event is an echo — skip it.
Why it breaks:
- Third-party APIs aggressively mutate data upon ingestion. You send a boolean
true, the provider normalizes it to an integer1. You send an ISO 8601 timestamp, the provider truncates the milliseconds. The echo payload's hash will not match your stored hash. - Timestamps, auto-generated fields like
last_modified_at, and system-injected metadata mean the hash almost never matches even when the human-relevant data has not changed. You end up maintaining a growing exclusion list of fields to strip before hashing. - This pattern fails if your transformation is not deterministic. Adding a
last_syncedtimestamp makes the same record produce a different output each time, defeating hash comparison entirely. - Race conditions between concurrent writes make the stored hash unreliable.
Hashing is useful as a deduplication layer, not as your entire contention strategy. If two systems both legitimately edit different fields on the same record, the hash changes, and the loop-prevention problem remains.
3. Custom Flag Fields
Create a custom field on your records (like sync_source=my_app or sync_required=yes/no) to distinguish between human updates and automated updates. Only trigger syncs when the flag indicates a human change. Valence documents both record-based markers and context-based markers, and MuleSoft's Salesforce-to-Salesforce templates extend objects with external ID fields to pair records across orgs.
Why it breaks:
- You need to create and manage a custom field in every connected app. Some APIs — especially HRIS systems — do not support custom fields at all.
- Relies on the triggering system correctly filtering on the custom field. Many webhook implementations do not support field-level trigger conditions.
- You need to ensure human edits correctly reset the flag. Otherwise valid changes get suppressed.
- You are polluting your customers' production schemas with integration plumbing. You cannot reasonably ask every enterprise customer to alter their Salesforce schema, add custom properties in HubSpot, and configure new fields in Zendesk just to install your integration. The onboarding friction will kill your conversion rate.
| Approach | Works for 3 integrations? | Works for 30? | Per-integration code? | Customer-visible changes? |
|---|---|---|---|---|
| Dedicated users | Yes | No | Yes | License costs |
| Payload hashing | Sometimes | No | Yes | None |
| Custom flags | Yes | No | Yes | Schema pollution |
The common thread: every traditional approach requires integration-specific code and configuration. The loop prevention itself is not hard. Doing it differently for Salesforce, HubSpot, Jira, BambooHR, Zendesk, and 40 others is what kills your engineering team. If you want to understand the true cost of these architectural compromises, read our analysis on 3 models for product integrations: a choice between control and velocity.
The Role of Webhooks in Real-Time Bidirectional Sync
Webhooks should wake up your sync engine. They should not be the only source of truth.
Provider behavior is wildly inconsistent. Microsoft Graph validates subscriptions by POSTing a validationToken and expects you to echo it back. Slack sends a challenge for URL verification. HubSpot retries failed webhook notifications up to 10 times. Stripe does not guarantee ordering and can send duplicates. Shopify explicitly recommends idempotent processing and periodic reconciliation jobs because webhook delivery is never guaranteed.
Exactly-once delivery is fantasy at the third-party boundary. Build for duplicates, retries, delayed events, and out-of-order delivery from day one. If your architecture treats every incoming webhook as a verified, actionable data mutation, you will inevitably build an echo chamber.
A well-architected webhook ingestion layer handles five things before your product logic runs:
- Verification challenges — Responding to initial handshake requests (Slack, Microsoft Graph) without forwarding them to your business logic.
- Signature validation — Verifying event authenticity using timing-safe comparison. The format varies per provider: HMAC, JWT, Basic Auth, Bearer tokens.
- Fast acknowledgment — Returning a
200 OKimmediately and queuing the actual processing. If a slow database query causes a timeout, the provider retries the webhook, artificially inflating your event volume and triggering false loops. - Payload normalization — Transforming provider-specific event structures into a common schema before your sync logic sees them. A
contact.updatedevent from HubSpot looks nothing like one from BambooHR. - Echo detection — Determining whether the event was triggered by a human action or an automated sync, so downstream processors can skip echoes.
flowchart LR
A[Third-Party<br>Webhook] --> B[Ingestion Layer]
B --> C{Verification<br>Challenge?}
C -->|Yes| D[Respond to<br>Provider]
C -->|No| E[Signature<br>Validation]
E --> F[Payload<br>Transform]
F --> G{Echo<br>Event?}
G -->|Yes| H[Drop / Log]
G -->|No| I[Normalize to<br>Unified Schema]
I --> J[Enqueue for<br>Sync Processing]The key insight: the webhook ingestion layer is where loop prevention belongs, not in your application code. If your ingestion layer can normalize events from any provider into a unified schema and tag their origin, your sync logic needs one set of rules — not one per integration.
If your source system is Salesforce, Change Data Capture is worth investigating over polling or basic webhooks. CDC change events include header metadata identifying the origin of the change, which Salesforce explicitly designed for ignoring changes generated by your own client. Salesforce retains CDC events for 72 hours, giving you a replay window when consumers fall behind.
For a deeper look at building reliable ingestion pipelines, see our guide on Designing Reliable Webhooks: Lessons from Production.
How to Architect Loop-Free Syncs Using Truto RapidBridge
Bidirectional sync requires an architecture that inherently understands the concept of origin, time, and state — without relying on integration-specific code. Truto approaches this problem through two complementary mechanisms: a declarative webhook ingestion layer for real-time events, and RapidBridge for incremental polling-based reconciliation.
Filtering Echo Events at the Edge
Truto's unified webhook ingestion layer uses JSONata expressions to normalize and filter events before they reach your core logic. When a third-party webhook hits the Truto ingress, it passes through a transformation pipeline where you can inspect the raw payload and selectively drop events based on provider-specific metadata — all defined as configuration, not application code.
webhooks:
hubspot: |
(
$is_echo := body.properties.last_modified_source = "INTEGRATION";
$is_echo ? null : {
"event_type": "updated",
"raw_event_type": body.subscriptionType,
"raw_payload": $,
"resource": "crm/contacts",
"method": "get",
"method_config": {
"id" : body.objectId
}
}
)The JSONata expression evaluates the raw payload. If it detects that the modification source was an integration, it returns null, dropping the event at the edge. Truto never forwards the echo to your system.
If the event is a valid human update, Truto enqueues it and triggers data enrichment. Many webhook payloads are "skinny" — they only contain an ID. Through the method_config block, Truto dynamically fetches the fully enriched unified data model directly from the provider API and delivers a standardized payload to your system. You always get the complete, up-to-date record without making secondary API calls from your application that could trigger rate limits.
sequenceDiagram
participant Human
participant Provider API
participant Truto Edge
participant Your SaaS
Your SaaS->>Provider API: PATCH /resource (Sync)
Provider API->>Truto Edge: Webhook (resource.updated)
Note over Truto Edge: JSONata evaluates payload.<br>Detects automated source.
Truto Edge--xYour SaaS: Event dropped. Loop prevented.
Human->>Provider API: Manual Update
Provider API->>Truto Edge: Webhook (resource.updated)
Note over Truto Edge: JSONata evaluates payload.<br>Detects human source.
Truto Edge->>Provider API: Fetch full enriched record
Provider API-->>Truto Edge: Full record data
Truto Edge->>Your SaaS: Unified Webhook EventThis architecture completely isolates your application logic from the nuances of provider-specific echo behavior. Your system only receives unified events that represent genuine, actionable data mutations. Adding a new integration's webhook support does not require deploying new application logic — it requires defining a new JSONata mapping expression.
Incremental Delta Syncs for Reconciliation
Webhooks handle the real-time path. But most production bidirectional syncs also need a polling-based reconciliation pass — a scheduled job that catches anything webhooks missed. And they will miss things; webhooks are best-effort delivery from most providers. Shopify's own documentation explicitly recommends reconciliation jobs for this reason.
This is where incremental delta syncs become the primary mechanism for loop prevention on the polling side. RapidBridge's native previous_run_date tracking allows declarative incremental syncs without writing custom timestamp logic:
{
"resource": "crm/contacts",
"method": "list",
"query": {
"updated_at": {
"gt": "{{previous_run_date}}"
}
}
}The previous_run_date is a system-managed attribute resolved at runtime to the timestamp of the last successful sync run for this specific resource and connected account. On the first run, it defaults to epoch (1970-01-01T00:00:00.000Z), pulling a full initial sync. Every subsequent run pulls only the delta.
This pattern inherently prevents loops in the polling path:
- Write-then-advance: After your sync job writes to the target, the echoed record's
updated_atwill be after the currentprevious_run_datebut before the next run's watermark (since the current run completes and advances the marker). The echo falls in a narrow window and gets naturally filtered out. - Idempotent writes: Combining delta sync with idempotent upserts using external IDs or composite keys means even if an echo slips through, it produces no net change to the target record.
For resources that depend on parent records (like ticket comments that depend on tickets), RapidBridge supports declarative dependencies:
{
"resources": [
{
"resource": "ticketing/tickets",
"method": "list",
"query": {
"updated_at": { "gt": "{{previous_run_date}}" }
}
},
{
"resource": "ticketing/comments",
"method": "list",
"depends_on": "ticketing/tickets",
"query": {
"ticket_id": "{{resources.ticketing.tickets.id}}"
}
}
]
}No custom code to maintain timestamps. No per-integration watermark logic. No database tables you need to manage for tracking sync state.
Combine both paths for production reliability. Use webhook-driven real-time sync for low-latency updates, and a scheduled delta sync job as a reconciliation safety net. The delta sync catches anything the webhook path missed, and the previous_run_date watermark ensures it does not reprocess the entire dataset each time.
Handling the Initial Sync After Account Connection
A common trap when solving the bulk extraction problem: a customer connects their Salesforce account, and you need to pull all existing contacts before the bidirectional sync starts. If you do not handle this carefully, the initial data load triggers webhooks on the target system, which bounce back and create duplicates.
Truto supports an event-driven kickoff. When an integrated account becomes active, a lifecycle event (integrated_account:active) is emitted. Your system listens for this event and triggers the initial sync job:
curl -X POST https://api.truto.one/sync-job-run \
-H 'Authorization: Bearer <api_token>' \
-H 'Content-Type: application/json' \
-d '{
"sync_job_id": "<sync_job_id>",
"integrated_account_id": "<from_webhook_payload>",
"webhook_id": "<webhook_id>"
}'Because the initial run uses previous_run_date set to epoch, it pulls everything. Subsequent runs pull only deltas. No special "initial load" code path required. You can also force a full re-sync at any time by passing ignore_previous_run: true — useful for recovery after incidents or permission changes.
A Reference Architecture for Loop-Free Bidirectional Sync
Putting it all together, here is the architecture that works in production:
flowchart TB
subgraph Real-Time Path
W1[Third-Party Webhook] --> IL[Unified Ingestion Layer]
IL --> VF[Verify + Transform]
VF --> EM[Event Mapping<br>via JSONata]
EM --> EF{Echo<br>Filter}
EF -->|Clean Event| Q1[Event Queue]
EF -->|Echo| DROP[Drop]
end
subgraph Scheduled Path
CRON[Scheduled Trigger] --> DS[Delta Sync Job]
DS --> API[Unified API<br>with previous_run_date]
API --> Q2[Data Queue]
end
Q1 --> SL[Your Sync Logic]
Q2 --> SL
SL --> TW[Write to Target<br>via Unified API]Key principles:
- Two paths, one contract. Both the real-time webhook path and the scheduled delta path produce the same unified data schema. Your sync logic has one code path, not two.
- Filter at ingestion, not in business logic. Echo detection happens before events reach your application. Your engineers never see phantom updates.
- Watermarks, not wall clocks. The
previous_run_datepattern ties reconciliation to successful completion, not arbitrary time windows. If a sync job fails, it retries from the same watermark — no data loss. - Idempotent writes everywhere. Use external IDs or composite keys for upserts. If an echo sneaks through both filters, the write is a no-op.
Stop Writing Integration-Specific Code for Contention Resolution
Let's be honest about what the traditional approaches really require at scale.
If you support 30 integrations across CRM, HRIS, ATS, and ticketing categories using the dedicated-integration-user approach, you need 30 different user provisioning workflows (each vendor's admin panel works differently), 30 different trigger filter configurations (SOQL WHERE clauses for Salesforce, JQL for Jira, custom query parameters for everyone else), and 30 different test suites to verify loop prevention still works after each vendor's API update. That is not an integration strategy. That is a full-time job for a team doing nothing but maintaining loop-prevention boilerplate instead of building the integrations your B2B sales team actually asks for.
The alternative is pushing contention resolution into an abstraction layer that handles it generically. Every integration — whether Salesforce, HubSpot, BambooHR, or Linear — runs through the same execution pipeline. Webhook verification, payload normalization, event mapping, data enrichment, and outbound delivery all use declarative configuration (JSONata expressions and structured schema mappings) rather than per-integration code branches. This is where a unified API approach pays off — not because it magically eliminates the complexity, but because it concentrates it in one place instead of smearing it across 30 codebases.
This means:
- No custom SOQL queries to filter integration-user updates in Salesforce.
- No per-integration webhook handlers with bespoke loop-detection logic.
- No custom fields polluting your customers' CRM schemas.
- Adding a new integration means adding configuration, not deploying new code.
Trade-off disclosure: A unified API approach works exceptionally well for standard CRUD operations and common data models. If your sync requires deeply custom business logic — like triggering a subscription in Chargebee when a Salesforce Opportunity moves to Closed-Won — you will still need orchestration code on top. No abstraction layer eliminates application-specific logic. But it should eliminate plumbing. For multi-step orchestration patterns, see Architecting Real-Time CRM Syncs for Enterprise.
The honest caveat is that zero integration-specific code does not eliminate contention entirely. You still need to decide on ownership models — object-level, record-level, or field-level — for each synced resource. The difference is where you implement that logic. With a declarative integration layer, you implement it once at the platform boundary instead of scattering it across webhook handlers, cron jobs, and CRM triggers.
What to Do Next
If you are currently debugging an infinite loop in production, here is a triage checklist:
- Pick an ownership model for each synced object. Decide whether the source of truth operates at the object level, record level, or field level.
- Identify the echo source. Check your webhook logs for events that arrive within seconds of your outbound writes. The timestamp and event metadata will tell you if it is a bounce-back.
- Split webhook ingestion from business logic. Verify, normalize, enqueue, and acknowledge fast. If your webhook handler does processing synchronously, a slow query will cause provider retries that amplify the loop.
- Switch from full syncs to delta syncs. If your scheduled jobs pull the full dataset every time, you are multiplying echo risk and burning API quota. Implement
updated_at > last_successful_runfiltering immediately. - Evaluate your abstraction layer. If you are maintaining loop-prevention code for more than 5 integrations, the economics favor pushing that logic into a unified API layer. The maintenance cost of bespoke solutions grows linearly; a declarative approach stays flat.
Bi-directional sync is a hard problem. But it is a solved hard problem — the patterns exist, and you do not need to reinvent them for every integration your product supports.
FAQ
- What causes infinite loops in bidirectional API syncs?
- Infinite loops occur when two systems are both authorized to read and write the same data, and neither can distinguish between a human update and an automated sync update. A single change triggers a webhook, which triggers a write-back, which triggers another webhook — creating an endless echo cycle that drains API quotas and corrupts data.
- What are vampire records in bidirectional sync?
- Vampire records are data entries that bounce back and forth between two integrated systems indefinitely during a bidirectional delta sync. They consume API limits, clutter audit history, trigger false alerts, and can exhaust daily API quotas in minutes during bulk operations.
- Why doesn't the dedicated integration user approach scale?
- It requires a separate paid user license per connected app per customer, each vendor exposes the 'updated by' field differently (or not at all), and you need custom trigger filter logic for every integration. At 20+ integrations, you are maintaining 20 different loop-prevention implementations — each with its own provisioning workflow and test suite.
- What is the difference between webhook-based and polling-based bidirectional sync?
- Webhook-based sync provides near-real-time updates when the third-party fires an event, but delivery is best-effort and unreliable. Polling-based delta sync runs on a schedule and queries for all changes since the last successful run. Production systems should use both: webhooks for speed, polling for reliability as a reconciliation safety net.
- How does a unified API help with bidirectional sync loop prevention?
- A unified API centralizes webhook ingestion, payload normalization, and echo filtering into a single declarative configuration layer. Instead of writing bespoke loop-prevention code for each integration (SOQL filters for Salesforce, JQL for Jira, custom query parameters for everyone else), you define one set of rules that applies across all providers.