Skip to content

CRM Integration Implementation Recipes: Salesforce & HubSpot Architecture (2026)

Architectural recipes for bidirectional CRM sync plus zero-data-retention patterns for ERP integrations: streaming, ephemeral credentials, and safe observability.

Uday Gajavalli Uday Gajavalli · · 38 min read
CRM Integration Implementation Recipes: Salesforce & HubSpot Architecture (2026)

Building a CRM integration to Salesforce or HubSpot looks trivial in a sandbox environment. You authenticate once, make a single HTTP request to a contacts endpoint, parse a JSON response, and render it in your UI. Then you push it to production with a hundred enterprise customer accounts behind it and watch HTTP 429s, REQUEST_LIMIT_EXCEEDED errors, broken pagination cursors, and __c custom field mismatches turn your sprint into a six-month maintenance project.

The market opportunity is massive. Fortune Business Insights reports the B2B SaaS market is expanding rapidly, projected to reach $634.39 billion in 2026 at a 27.54% CAGR. However, the European B2B CX Benchmark Report 2025-2026 indicates companies typically manage customer data across an average of 12 different systems. Salesforce and HubSpot sit at the center of that fragmented ecosystem, which means your integration is not just talking to a CRM - it is competing for API budget with every other middleware tool already installed in your customer's org.

This guide provides actionable architectural blueprints for building native-feeling integrations with Salesforce and HubSpot, specifically addressing the technical hurdles that cause integrations to fail at scale. If you want the wider context on why these projects drain engineering time, read Building Native CRM Integrations Without Draining Engineering in 2026. The rest of this post is your technical recipe book.

The "Just a Few API Calls" Trap: Why CRM Integrations Fail in Production

B2B SaaS CRM integration failure occurs when engineering teams scope third-party API connections based on vendor "Getting Started" guides, ignoring the production realities of strict rate limits, undocumented pagination behaviors, and highly customized data schemas.

A CRM integration is not a simple HTTP client. It is a distributed system, especially when architecting real-time CRM syncs for enterprise. When your product manager asks for a Salesforce integration, the initial engineering estimate usually covers the basic HTTP request path. That represents roughly 10% of the actual work. The remaining 90% involves managing OAuth token lifecycles, handling burst rate limits across tenants, building exponential backoff queues, and dealing with custom objects that break your static data models.

Three failure modes show up in production for almost every team:

  • Burst exhaustion: Your sync runs fine at 10 customers and falls over at 100 because you treated rate limits as a single number instead of a token bucket.
  • Custom schema drift: Half your enterprise accounts have custom objects or properties you have never seen, and your hardcoded field mappings start returning null for the data customers actually care about.
  • Silent pagination loss: Cursor-based pagination breaks when a record is created or deleted mid-walk, and your incremental sync starts missing records without throwing a single error.

The rest of this article gives you a recipe for each of these, then explains the architectural shift that removes most of the work.

Bidirectional Sync Architecture: Real-Time vs Batch

Before diving into individual recipes, it helps to understand the full picture. The question most teams are really asking is: "How do I sync customer data bidirectionally between my app and HubSpot (or Salesforce) without things breaking?" The answer has three moving parts.

  1. Inbound path (CRM → Your App): The CRM pushes change events to your webhook endpoint. Your app processes them, maps them to your internal data model, and writes them to your database.
  2. Outbound path (Your App → CRM): When a record changes in your app, you push the update to the CRM's API. You need to prevent the resulting CRM webhook from echoing back and triggering a loop.
  3. Repair sync (periodic reconciliation): A scheduled batch job that sweeps updatedAt windows in both directions, catching anything the real-time path missed - dropped webhooks, failed writes, or drift that accumulates over time.

Real-time sync (webhooks) gives you sub-minute latency for most changes. Batch sync (polling lastmodifieddate windows) gives you completeness guarantees. You need both. If you skip the repair path, you are not building sync - you are building a demo.

flowchart LR
    subgraph RT[Real-Time Path]
        direction TB
        A1[CRM Webhook] -->|contact.propertyChange| B1[Your Webhook Endpoint]
        B1 --> C1[Map + Write to App DB]
        D1[App Record Changed] --> E1[Push to CRM API]
        E1 --> F1[Store Outbound Fingerprint]
    end
    subgraph RP[Repair Path]
        direction TB
        G1[Scheduled Job] --> H1[Poll CRM<br>updatedAt > last_sync]
        H1 --> I1[Diff + Reconcile]
        G1 --> J1[Poll App DB<br>updated_at > last_sync]
        J1 --> I1
    end

Preventing Infinite Loops with Fingerprints

The echo problem is the single biggest trap in bidirectional sync. When your app writes a contact update to HubSpot, HubSpot fires a contact.propertyChange webhook back to your endpoint. If you process that event naively, it writes back to your app, which triggers another outbound push, creating an infinite loop.

The standard prevention pattern:

  1. Before writing to HubSpot, compute a fingerprint (hash of the field values you are sending) and store it keyed by (record_id, direction).
  2. When an inbound webhook arrives, compute the fingerprint of the incoming data.
  3. If it matches a stored outbound fingerprint, it is an echo - acknowledge and skip.
  4. If it does not match, it is a genuine change from a HubSpot user - process it normally.

This pattern works regardless of the CRM. It is the same for Salesforce outbound messages, Pipedrive webhooks, or any other provider. The recipes below cover the specific implementation details for each part of this pipeline. For a deeper walkthrough of the full bidirectional architecture, see How to Architect a Bidirectional HubSpot Sync (Without Infinite Loops).

Recipe 1: Navigating HubSpot API Rate Limits and Pagination

As covered in our 2026 architecture guide to building a HubSpot integration, HubSpot's API is generally developer-friendly, utilizing standard REST conventions and JSON payloads. However, its rate limiting strategy is aggressive. HubSpot's API is the textbook example of a token bucket model. Misread it as a simple "X requests per second" cap and you will burn your daily quota fighting 429s.

The Actual HubSpot Rate Limit Numbers (2026)

For public OAuth apps, the burst limit is 110 requests per 10 seconds per installed account. For private apps on Pro and Enterprise tiers, the burst limit is 190 requests per 10 seconds.

Daily quotas matter more than people think. Free/Starter plans allow 250,000 daily requests. Enterprise plans offer up to 1,000,000 daily, with add-ons available for more capacity.

The Search API is the silent killer. The CRM search API (/crm/v3/objects/contacts/search, etc.) is capped at 5 requests per second across all search endpoints. That is not per endpoint - it is shared across contacts, deals, companies, and every other object type. If you use search for deduplication checks on inbound writes, it - not the burst limit - becomes your bottleneck.

Implementing a Token Bucket Rate Limiter

To handle this, your architecture must decouple integration logic from direct HTTP execution. You need an outbound request queue governed by a token bucket algorithm.

graph TD
    A[Application Logic] -->|Enqueue Request| B(Redis Queue)
    B --> C{Token Bucket Check}
    C -->|Tokens Available| D[Execute HTTP Request]
    C -->|Tokens Depleted| E[Delay & Re-queue]
    D --> F{Response Status}
    F -->|200 OK| G[Process Data]
    F -->|429 Too Many Requests| H[Exponential Backoff]
    H --> B

A naive sleep(100) between requests leaves throughput on the table. The token bucket model means you can spike above the average rate temporarily. A well-designed client takes advantage of this: send requests as fast as you need to, but build in breathing room so the bucket recovers.

Here is a practical TypeScript implementation of a token bucket sized for HubSpot's public app tier:

// Token bucket for HubSpot - 110 burst, ~10 tokens/sec refill (public app)
class HubSpotRateLimiter {
  private tokens = 110
  private lastRefill = Date.now()
  private readonly refillPerMs = 10 / 1000
  private readonly capacity = 110
 
  async acquire(): Promise<void> {
    this.refill()
    if (this.tokens >= 1) {
      this.tokens -= 1
      return
    }
    const waitMs = Math.ceil((1 - this.tokens) / this.refillPerMs)
    await new Promise(r => setTimeout(r, waitMs))
    return this.acquire()
  }
 
  private refill() {
    const now = Date.now()
    this.tokens = Math.min(
      this.capacity,
      this.tokens + (now - this.lastRefill) * this.refillPerMs
    )
    this.lastRefill = now
  }
}

Pair this with exponential backoff and jitter on 429s. If you receive an HTTP 429 response, you must respect the Retry-After header. Pair that with a small randomized delay (0-9 seconds of jitter) before each outbound call so 10,000 enrollments don't align perfectly. If you continue to hammer the API without jitter, HubSpot will temporarily ban your OAuth application from making further requests for that specific tenant.

Reading HubSpot Rate Limit Headers for Adaptive Throttling

The static token bucket above works, but you can do better. HubSpot returns rate limit state in every response via headers. Reading these headers lets your client adapt in real-time instead of guessing.

The relevant headers:

Header Meaning
X-HubSpot-RateLimit-Max Maximum requests allowed in the current window
X-HubSpot-RateLimit-Remaining Requests remaining before the limit resets
X-HubSpot-RateLimit-Interval-Milliseconds Duration of the current rate limit window

Here is an adaptive rate limiter that syncs its internal state from HubSpot's response headers on every call:

class AdaptiveHubSpotLimiter {
  private remaining = 110
  private windowMs = 10_000
  private windowStart = Date.now()
 
  updateFromHeaders(headers: Headers) {
    const rem = headers.get('X-HubSpot-RateLimit-Remaining')
    const max = headers.get('X-HubSpot-RateLimit-Max')
    const interval = headers.get('X-HubSpot-RateLimit-Interval-Milliseconds')
    if (rem !== null) this.remaining = parseInt(rem, 10)
    if (interval !== null) this.windowMs = parseInt(interval, 10)
  }
 
  async acquire(): Promise<void> {
    if (this.remaining > 5) { // keep a 5-request safety buffer
      this.remaining--
      return
    }
    const elapsed = Date.now() - this.windowStart
    const waitMs = Math.max(0, this.windowMs - elapsed) + 100
    await new Promise(r => setTimeout(r, waitMs))
    this.windowStart = Date.now()
    this.remaining = 110 // reset to burst capacity
  }
}
 
// Usage: wrap every outbound HubSpot call
async function hubspotFetch(
  url: string, opts: RequestInit, limiter: AdaptiveHubSpotLimiter
) {
  await limiter.acquire()
  const res = await fetch(url, opts)
  limiter.updateFromHeaders(res.headers)
  if (res.status === 429) {
    const retryAfter = parseInt(res.headers.get('Retry-After') ?? '10', 10)
    await new Promise(r => setTimeout(r, retryAfter * 1000 + Math.random() * 1000))
    return hubspotFetch(url, opts, limiter) // retry once
  }
  return res
}

The key detail: always keep a safety buffer of 5-10 remaining requests. If you drain the bucket to zero, concurrent requests in flight will all 429 simultaneously. The buffer gives you room to decelerate gracefully.

Handling Cursor-Based Pagination and Batching

If you only learn one thing about HubSpot efficiency: batch endpoints count as one request. HubSpot's batch create/update endpoints let you process up to 100 records in a single API call. From a rate limit perspective, that is 100x more efficient than individual calls.

API pagination consistency is another major hurdle. HubSpot uses cursor-based pagination. When you request a list of contacts, the response includes a paging.next.after token.

{
  "results": [
    { "id": "123", "properties": { "firstname": "Alice" } }
  ],
  "paging": {
    "next": {
      "after": "NTI1Cg%3D%3D",
      "link": "?after=NTI1Cg%3D%3D"
    }
  }
}

Your integration must recursively or iteratively fetch pages using this after parameter until the paging.next object is null. Do not rely on offset-based pagination for HubSpot, as it is inefficient and HubSpot reorders results on deletes, meaning you will drop records. For more on bidirectional sync challenges, review our bidirectional HubSpot sync tutorial.

Special Case: CRM Search API Pacing

The CRM Search API deserves its own rate limiter. As noted above, the standard burst limit (110 requests / 10 seconds for public apps) does not apply to search endpoints. The Search API is capped at approximately 4-5 requests per second, shared across all search endpoints at the account level - contacts, deals, companies, tickets, everything.

This has three practical implications:

  1. Separate your queues. Run search requests through a dedicated limiter with a 4 req/s ceiling, independent of your CRUD limiter. If you mix them in one queue, a burst of search calls will starve your other operations.
  2. Maximize page size. The Search API supports limit up to 200 records per response. At 4 requests per second with 200 records each, you get 800 records/second throughput. At the default of 10 records per page, you get 40 records/second. That is a 20x difference.
  3. Respect the 10,000-result cap. A single search query can only return 10,000 results total, even with pagination. If you need more, segment your queries by a range filter (e.g., createdate ranges) so each segment stays under 10,000.
// Dedicated search limiter - 4 req/s with 250ms spacing
class SearchApiLimiter {
  private lastCall = 0
  private readonly minGapMs = 250 // 4 req/s = 250ms between calls
 
  async acquire(): Promise<void> {
    const now = Date.now()
    const elapsed = now - this.lastCall
    if (elapsed < this.minGapMs) {
      await new Promise(r => setTimeout(r, this.minGapMs - elapsed))
    }
    this.lastCall = Date.now()
  }
}

If you use search for deduplication checks during writes (e.g., "does this email already exist?"), batch those checks before you start writing. Running interleaved search-then-write loops is the fastest way to exhaust your search budget.

Recipe 2: Conquering Salesforce Governor Limits and SOQL Queries

As we noted in our hands-on guide to building a Salesforce API integration, Salesforce is an entirely different beast compared to HubSpot. It is essentially a massive relational database exposed over the web. Salesforce integrations frequently fail in production not because of load, but because they violate strict governor limits.

The Limits That Actually Bite

The base daily quota varies by edition: Enterprise Edition starts at 100,000 calls per day plus 1,000 per user license. That sounds like a lot until you remember every middleware tool in the customer's org draws from the same pool.

The Daily API Request Limit is a soft limit, and your org is allowed to exceed it temporarily. The 403 error with REQUEST_LIMIT_EXCEEDED is what you actually need to handle when the system protection limit kicks in.

Concurrency is the real trap. Salesforce limits the number of concurrent long-running (20+ second) requests to 25 on production orgs. If the limit is exceeded, any new concurrent requests will not be processed. Long-running SOQL queries during a backfill will block your customer's other integrations.

Building Safe SOQL Queries and Keyset Pagination

To fetch data efficiently from Salesforce, you must use the Salesforce Object Query Language (SOQL). REST API endpoints exist for individual records, but bulk extraction requires dynamic SOQL construction.

SELECT Id, FirstName, LastName, Email, AccountId 
FROM Contact 
WHERE LastModifiedDate > 2026-01-01T00:00:00Z 
ORDER BY Id ASC 
LIMIT 2000

Notice the ORDER BY Id ASC. This is critical. Salesforce's standard offset pagination (OFFSET 2000) maxes out at 2,000 records. If you need to sync 50,000 contacts, standard offset pagination will hard-fail. You must implement "keyset pagination" - tracking the highest Id from the previous batch and using it in the WHERE clause of the next query (WHERE Id > 'Last_Seen_Id').

Never construct SOQL by string concatenation against user input. For dynamic filtering across customers with different field sets, build the query off the org's actual describe metadata, not your assumption of what fields exist:

async function buildContactQuery(sf: Connection, filters: Filter[]) {
  const describe = await sf.sobject('Contact').describe()
  const validFields = new Set(describe.fields.map(f => f.name))
  const where = filters
    .filter(f => validFields.has(f.field))
    .map(f => `${f.field} ${f.op} ${escape(f.value)}`)
    .join(' AND ')
  return `SELECT Id, FirstName, LastName, Email FROM Contact WHERE ${where} LIMIT 200`
}

Utilizing Bulk API 2.0 for Backfills

For any operation involving more than a few thousand records, you must abandon the standard REST API and use Salesforce Bulk API 2.0. This is an asynchronous API designed for large data volumes that allows enormous throughput: up to 150 million records per rolling 24 hours. Each batch can contain up to 10,000 records, and each 10k batch counts as only 1 call against the daily limit.

  1. Create a Job: Send a POST request to define the object (e.g., Contact) and operation (e.g., query, insert, upsert).
  2. Upload Data: If inserting, upload CSV data to the job endpoint.
  3. Close the Job: Signal to Salesforce that the upload is complete.
  4. Poll for Status: Continuously poll the job status endpoint until it reads JobComplete. Do not poll aggressively - check status every 30-60 seconds.
  5. Retrieve Results: Fetch the successful records and the error logs.

This architecture requires your application to maintain durable state. You cannot hold an HTTP connection open waiting for a Bulk API job to finish. You must persist the Job ID to your database and use a background worker to poll for completion.

Recipe 3: Handling Salesforce Custom Objects and HubSpot Custom Properties

This is where most unified data models break. No two enterprise Salesforce instances are identical. If your integration assumes that a Contact object only has FirstName, LastName, and Email, it will break immediately upon deployment to a mid-market or enterprise customer.

The Salesforce __c Suffix Pattern

In Salesforce, when a customer creates a custom field for "Lead Source Detail", the API exposes it as Lead_Source_Detail__c. Every custom field and custom object in Salesforce ends in __c.

Your integration must first query the Salesforce metadata API to discover available fields for the authenticated tenant:

GET /services/data/v59.0/sobjects/Contact/describe

This endpoint returns a massive JSON payload detailing every field. You must filter the field list where custom === true (or match /__c$/), cache this schema on a TTL (24 hours is fine), and emit those fields as an open-ended map. Read our deep dive on how to handle custom fields and custom objects in Salesforce via API for specific UI mapping patterns.

HubSpot Dynamic Properties

HubSpot handles custom data differently. Instead of appending suffixes, HubSpot nests custom data within a properties object.

{
  "properties": {
    "firstname": "John",
    "custom_industry_vertical": "SaaS",
    "internal_scoring_metric": "85"
  }
}

Crucially, HubSpot will not return custom properties unless you ask for them by name in the GET request. That single gotcha causes more "missing data" support tickets than anything else. You must discover them via /crm/v3/properties/contacts and explicitly request them:

const defaultProps = new Set([
  'firstname', 'lastname', 'email', 'phone',
  'jobtitle', 'address', 'city', 'state', 'zip', 'country'
])
 
const customProps = allProperties
  .filter(p => !defaultProps.has(p.name) && !p.hubspotDefined)
  .map(p => p.name)
 
// Include them explicitly in the GET call
const url = `/crm/v3/objects/contacts?properties=${[...defaultProps, ...customProps].join(',')}`

Recipe 4: Webhook Subscription and HMAC Verification for Inbound Sync

The inbound leg of a bidirectional sync starts with CRM webhooks. When a contact, deal, or company changes in HubSpot, a webhook pushes the event to your endpoint. Getting this right requires correct subscription setup, signature verification, and idempotent processing.

Setting Up HubSpot Webhook Subscriptions

HubSpot supports webhook subscriptions through the Webhooks API (for developer apps) and through workflow actions (for Operations Hub / Data Hub). For a programmatic bidirectional sync, you want the Webhooks API approach.

Key setup steps:

  1. Register your app in the HubSpot developer portal with the required scopes (e.g., crm.objects.contacts.read for contact events).
  2. Configure a target URL - this must be an HTTPS endpoint that responds within 5 seconds.
  3. Create subscriptions for the event types you need: contact.creation, contact.propertyChange, contact.deletion, deal.creation, deal.propertyChange, deal.deletion, etc.
  4. Activate subscriptions - they start in an inactive state and must be explicitly activated.

HubSpot batches webhook events: a single POST to your endpoint can contain up to 100 events. Each event in the batch may relate to a different object or subscription type. Your handler must iterate through the array and process each event individually.

Info

Webhook configuration changes take up to 5 minutes to propagate in HubSpot due to internal caching. When deploying a new webhook URL, keep the old endpoint active for at least 5 minutes to avoid dropped events during the transition.

HubSpot v3 Signature Verification Checklist

Every inbound webhook from HubSpot includes an X-HubSpot-Signature-v3 header and an X-HubSpot-Request-Timestamp header. You must verify both before processing any payload.

The verification steps:

  1. Check the timestamp. Reject the request if X-HubSpot-Request-Timestamp is more than 5 minutes old. HubSpot timestamps are in milliseconds, not seconds - this is a common source of bugs.
  2. Build the signature input. Concatenate (UTF-8): requestMethod + requestUri + requestBody + timestamp. The request URI must match exactly what HubSpot sent - watch for reverse proxies or load balancers that rewrite URLs.
  3. Compute the HMAC. Use HMAC SHA-256 with your app's client secret (not an API key or access token) as the key.
  4. Base64-encode the HMAC result.
  5. Compare the computed value to the X-HubSpot-Signature-v3 header using constant-time comparison to prevent timing attacks.
  6. Verify the raw body. The HMAC is computed on the exact bytes HubSpot sends. If middleware parses or reformats the body before your verification code runs, the signature will not match.
import { createHmac, timingSafeEqual } from 'crypto'
 
function verifyHubSpotV3(
  method: string,
  uri: string,
  rawBody: string,
  timestamp: string,
  signature: string,
  clientSecret: string
): boolean {
  // Reject stale requests (timestamp is in milliseconds)
  const age = Date.now() - parseInt(timestamp, 10)
  if (age > 5 * 60 * 1000) return false
 
  const input = `${method}${uri}${rawBody}${timestamp}`
  const computed = createHmac('sha256', clientSecret)
    .update(input, 'utf8')
    .digest('base64')
 
  return timingSafeEqual(Buffer.from(computed), Buffer.from(signature))
}
Warning

Do not skip verification. Without HMAC checking, any attacker who discovers your webhook URL can inject fake CRM events into your sync pipeline. In a bidirectional system, a spoofed contact.propertyChange event would overwrite real data in your app and then propagate back to HubSpot.

Idempotent Event Processing

HubSpot does not guarantee exactly-once delivery. Webhooks are retried up to 10 times over 24 hours if your endpoint returns a non-2xx status. Your handler must be idempotent.

The simplest approach: store processed eventId values in a fast lookup (a database table with a unique index, or a cache with a 48-hour TTL). On each inbound event, check if the eventId has already been processed. If yes, respond 200 and skip. If no, process the event and record the eventId.

For propertyChange events specifically, compare the incoming propertyValue against your current record state rather than blindly overwriting. HubSpot does not guarantee event ordering, so a stale event could arrive after a newer one. Use the occurredAt timestamp within each event to determine the actual sequence.

Recipe 5: Deletion and Tombstone Strategies

Deletes are the most dangerous operation in a bidirectional sync. A missed deletion leaves ghost records. An overly aggressive deletion propagation can wipe data a user only meant to archive in one system.

CRM Deletion Events

HubSpot fires *.deletion webhook events (e.g., contact.deletion, deal.deletion) when a record is permanently deleted. The event payload contains the object ID but not the full record - the data is already gone by the time you receive the event.

Salesforce can notify you of deletions via outbound messages or by querying the getDeleted() endpoint on each object, which returns IDs deleted within a given time window.

Three strategies for handling deletions:

Strategy How It Works Best For
Hard delete Delete the record in your app immediately Compliance-driven systems (GDPR right to erasure)
Soft delete / tombstone Set a deleted_at timestamp and is_deleted = true flag; stop syncing the record but keep it for audit trails Most B2B SaaS apps
Archive only Mark as archived in your app; do not propagate the deletion back to the CRM Systems where accidental deletes are common

The recommended default is soft delete with tombstone. When you receive a contact.deletion event:

  1. Look up the record in your database by the CRM ID.
  2. If found, set deleted_at = now() and deleted_source = 'hubspot' (or 'salesforce').
  3. Stop including this record in outbound sync runs.
  4. Do not push a delete back to the CRM - the record is already gone there.

For the reverse direction (a user deletes a record in your app), you have a choice: propagate the delete to HubSpot via DELETE /crm/v3/objects/contacts/{id}, or simply stop syncing it. Propagating deletes is permanent and irrecoverable in HubSpot - there is no recycle bin API. Make this a configurable behavior per customer, not a hardcoded default.

Handling Bulk Deletes and GDPR

If a customer triggers a bulk deletion in HubSpot (e.g., a list cleanup), you may receive hundreds of contact.deletion events in a single batch webhook. Your handler needs to process these without overwhelming your database with individual DELETE queries. Batch your tombstone updates.

For GDPR data subject access requests, HubSpot provides a gdpr-delete endpoint. If you receive a deletion through that path, you must ensure full erasure in your system as well - tombstoning is not sufficient. Track the deletion source to distinguish compliance-mandated erasures from normal CRM housekeeping.

Recipe 6: Field Ownership and Conflict Resolution

In a bidirectional sync, two systems can update the same field on the same record at the same time. Without explicit ownership rules, you get last-write-wins chaos where changes silently overwrite each other.

The Decision Matrix

Before writing a single line of sync code, fill out this matrix for every field you plan to sync:

Field Owner Sync Direction Conflict Rule Example
Email HubSpot HubSpot → App Always use HubSpot Sales reps update emails in CRM
Lifecycle Stage HubSpot HubSpot → App Always use HubSpot Marketing owns the funnel
Plan Tier Your App App → HubSpot Always use App Billing system is source of truth
MRR Your App App → HubSpot Always use App Revenue data comes from your backend
Company Name Both Bidirectional Most recent updatedAt wins Either side may correct it
Phone Both Bidirectional HubSpot wins on conflict CRM is primary contact store

Three common conflict resolution strategies:

  1. Field-level ownership (recommended). Each field has exactly one authoritative source. plan_tier always comes from your app. lifecycle_stage always comes from HubSpot. No conflicts possible because writes only flow in one direction per field.
  2. Timestamp-based (last write wins). Compare updatedAt from both sides and take the newer value. Simple to implement but can lose data if clocks drift or events arrive out of order.
  3. Prefer-source-unless-blank. Use one system's value by default, but accept the other system's value if the preferred source is empty. Good for initial data enrichment, bad for ongoing sync.

Field-level ownership eliminates the most common class of sync bugs. It is more restrictive than last-write-wins, but it means you never have to debug "who overwrote this field and when" at 2am.

Implementing Field-Level Ownership in Code

type SyncDirection = 'hubspot_to_app' | 'app_to_hubspot' | 'bidirectional'
 
const fieldOwnership: Record<string, {
  direction: SyncDirection
  conflictRule: string
}> = {
  email:            { direction: 'hubspot_to_app', conflictRule: 'always_hubspot' },
  lifecycle_stage:  { direction: 'hubspot_to_app', conflictRule: 'always_hubspot' },
  plan_tier:        { direction: 'app_to_hubspot', conflictRule: 'always_app' },
  mrr:              { direction: 'app_to_hubspot', conflictRule: 'always_app' },
  company_name:     { direction: 'bidirectional',  conflictRule: 'latest_updated_at' },
}
 
function shouldApplyInboundChange(
  field: string, inboundValue: unknown, currentValue: unknown
): boolean {
  const rule = fieldOwnership[field]
  if (!rule) return false // unknown fields are not synced
  if (rule.direction === 'app_to_hubspot') return false
  if (rule.direction === 'hubspot_to_app') return true
  // bidirectional: compare timestamps or apply conflict rule
  return true
}

Store this configuration in a database or config file, not in code. When a customer asks you to change which system owns a field (and they will), you want to flip a config value, not ship a code change.

Monitoring, Metrics, and Alerting Playbook

A bidirectional sync that runs without monitoring is a ticking bomb. You will not notice dropped records, silent 429s, or accumulating drift until a customer reports it - usually weeks later.

Metrics to Track

Metric What It Measures Alert Threshold
crm.429_rate Percentage of outbound requests returning 429 > 5% over a 5-minute window
crm.search_429_rate 429 rate specifically for Search API > 2% (tighter because the budget is smaller)
webhook.inbound.count Inbound webhook events received per minute Drop to 0 for > 10 minutes (CRM delivery failure)
webhook.inbound.verification_failures HMAC signature mismatches Any non-zero count (potential spoofing or config error)
webhook.inbound.duplicate_rate Percentage of events already processed (by eventId) > 30% (indicates retries from slow acknowledgement)
sync.outbound.write_failures Failed writes to the CRM per hour > 10 per hour
sync.reconciliation.drift_count Records found out of sync during repair sweep > 1% of total synced records
sync.loop_detection Echo fingerprint matches per hour Sudden spike (fingerprint logic may be broken)
oauth.token_refresh_failures Failed OAuth token refreshes Any non-zero count (sync will halt entirely)

Reconciliation Job

Run a scheduled reconciliation job every 4-8 hours. The job:

  1. Fetches records updated since the last reconciliation window from both the CRM and your app.
  2. Compares field values for records that exist in both systems.
  3. Logs discrepancies and optionally auto-corrects them based on your field ownership rules.
  4. Reports sync.reconciliation.drift_count as a metric.

This is your safety net. If the real-time webhook path drops an event, the reconciliation sweep catches it within hours instead of weeks. If drift count trends upward over time, something in your real-time path is broken and needs investigation.

Building ERP Integrations Without Storing Customer Data

The recipes above assume you can persist synced records in your own database. For CRM data, that is usually fine. For ERP data - NetSuite, SAP S/4HANA, Oracle Fusion, Microsoft Dynamics 365 Finance & Operations - it often is not. Financial ledgers, employee PII, tax records, and payroll data trigger compliance obligations (SOX, GDPR, HIPAA for healthcare ERPs, PCI for anything touching card data) that make even short-lived storage a liability.

The answer is a zero data retention (ZDR) architecture: process customer data in memory, pass it through to its destination, and let it disappear when the request completes. Nothing on disk, nothing in queues, nothing in logs. Same design pattern applies whether you are building integrations from scratch or extending the CRM sync patterns above to also cover ERPs. This section is the hands-on playbook.

Overview: Zero Data Retention Recap

A zero data retention integration guarantees that no customer business data touches durable storage owned by the integration layer. Only three categories of state are persisted:

  • Connection metadata: integrated account ID, tenant identifier, integration type, status.
  • Encrypted credentials: OAuth tokens or API keys, encrypted at rest with a key you control (AES-GCM is the standard choice).
  • Operational metadata: request timestamps, HTTP status codes, latencies, error categories - never payloads.

Everything else - contact records, journal entries, employee records, invoice line items, purchase orders - flows through the process without ever being written to a database, log file, or queue payload.

This is a natural fit for ERP integrations because:

  1. Customer legal teams frequently block deployments that store financial or HR data in third-party systems.
  2. Data residency requirements (EU, India, Australia, UAE) become trivial when no data is stored.
  3. Breach blast radius is bounded to whatever is in memory at the moment of compromise, not years of historical records.
  4. "Privacy by design" and "data minimization" clauses in GDPR Article 25 and CCPA are satisfied by architecture, not policy.

The trade-off: you lose the ability to add value in-flight through caching, denormalization, or cross-tenant analytics. You are a stateless proxy. For ERP data, that is exactly what enterprise buyers want.

In-Memory Processing Patterns: Streaming vs Full-Payload

There are two ways to move data through memory without persisting it. Choose based on payload size and downstream consumer capabilities.

flowchart LR
    subgraph FullPayload ["Full-Payload Buffering"]
        direction TB
        A1["Upstream API<br>(SAP / NetSuite)"] --> B1[Buffer entire response<br>in memory]
        B1 --> C1[Transform + validate]
        C1 --> D1[Push to caller<br>as single response]
    end
    subgraph Streaming ["Streaming Pipeline"]
        direction TB
        A2["Upstream API<br>(SAP / NetSuite)"] --> B2[Read response<br>chunk by chunk]
        B2 --> C2[Transform on the fly]
        C2 --> D2[Write to caller<br>as stream]
        D2 --> B2
    end

Full-payload buffering is simpler. You fetch the entire upstream response into memory, transform it, then hand the transformed result to your caller. Use it when:

  • The payload is bounded (a single invoice, one employee record, one journal entry).
  • The downstream requires the complete response before it can act (e.g., signing an entire document).
  • You need to sort, group, or aggregate across the whole response.

Streaming is the safer default for ERP. Bulk exports from NetSuite SuiteQL or SAP OData can return hundreds of thousands of records. Buffering that in memory will OOM your process. In a streaming pipeline you read the upstream response chunk by chunk (JSON lines, CSV rows, or paginated batches), transform each chunk, and pipe it directly to the caller before the next chunk arrives.

// Streaming pipeline: no full payload ever lives in memory
async function streamSuiteQL(
  upstreamUrl: string,
  token: string,
  transform: (row: Record<string, unknown>) => Record<string, unknown>,
  writer: WritableStreamDefaultWriter<Uint8Array>
) {
  const res = await fetch(upstreamUrl, {
    headers: { authorization: `Bearer ${token}` },
  })
  if (!res.body) throw new Error('no body')
 
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()
  const encoder = new TextEncoder()
  let buffer = ''
 
  while (true) {
    const { value, done } = await reader.read()
    if (done) break
    buffer += value
 
    // Emit complete JSON lines as they arrive
    let newlineIdx: number
    while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
      const line = buffer.slice(0, newlineIdx)
      buffer = buffer.slice(newlineIdx + 1)
      if (!line.trim()) continue
      const row = JSON.parse(line)
      const mapped = transform(row)
      await writer.write(encoder.encode(JSON.stringify(mapped) + '\n'))
      // row + mapped go out of scope on the next iteration - GC-eligible
    }
  }
  await writer.close()
}

The key discipline: do not accumulate. No const allRows = []; allRows.push(row). Every intermediate value must be eligible for garbage collection as soon as its transformed output is emitted. If you find yourself writing .reduce() or Array.from() on a stream, stop and ask whether the aggregation can happen on the caller's side instead.

Safe Memory and Timeout Limits, and Buffering Strategies

Zero data retention runtimes are usually stateless workers (serverless functions or short-lived containers) with strict caps. Design around these limits explicitly:

Constraint Typical Limit Design Implication
Memory ceiling 128 MB - 1 GB Stream anything larger than ~10% of ceiling
Wall-clock timeout 30 seconds - 15 minutes Break long jobs into paginated resumable steps
Request body size 6 MB - 100 MB Never accept a full ERP export as a request body
Concurrent execution 10 - 1000 per tenant Rate limit before you fan out

Concrete rules:

  • Cap buffer sizes explicitly. If you must buffer, allocate a fixed-size buffer and reject anything larger with a clear error. Never use unbounded arrays.
  • Set per-request memory watermarks. Emit a metric when memory usage crosses 70% of the ceiling. That is your signal to switch a fetcher to streaming.
  • Timeout upstream calls aggressively. If your worker has a 30-second budget, give the upstream call 20 seconds and reserve 10 for downstream write plus overhead. A hung ERP query should not eat your entire runtime.
  • Prefer chunked responses. Ask ERP APIs for paginated results with small page sizes (500-2000 rows) rather than a single mega-response. NetSuite SuiteQL supports LIMIT / OFFSET; SAP OData supports $top / $skip; Dynamics 365 supports $top with server-side paging.
  • Backpressure the writer. When streaming to a caller, respect their consumption rate. If they slow down, you slow down. pipeTo in the Web Streams API handles this automatically; write() returns a promise you must await.

A useful pattern is the bounded buffer: allocate a ring buffer of, say, 5000 records. Read into the buffer, transform, flush to the writer, repeat. Memory usage stays flat regardless of total payload size.

async function boundedStream<T, U>(
  source: AsyncIterable<T>,
  transform: (row: T) => U,
  sink: (batch: U[]) => Promise<void>,
  bufferSize = 5000,
) {
  let buffer: U[] = []
  for await (const row of source) {
    buffer.push(transform(row))
    if (buffer.length >= bufferSize) {
      await sink(buffer)
      buffer = [] // old buffer becomes GC-eligible
    }
  }
  if (buffer.length) await sink(buffer)
}

Retry, Idempotency, and Reconciliation Without Persistence

The classic retry pattern says: on failure, persist the request, retry with backoff, and give up after N attempts. Zero data retention breaks that model - you cannot persist the request because the request contains customer data.

The workable pattern:

  1. Retries stay in-process. For transient failures (5xx, connection reset, 429), retry with exponential backoff and jitter within the current worker invocation. Do not enqueue the payload for later.
  2. Failures propagate up. If in-process retries exhaust, return an error to the caller with a correlation ID. The caller (who owns the data) can decide whether to retry with the original payload.
  3. Idempotency keys are caller-supplied. The caller passes an idempotency key (typically a UUID derived from their own record ID plus a version). Your integration layer forwards it to the ERP if the ERP supports idempotency headers - SAP S/4HANA Cloud supports Idempotency-Key on many endpoints; NetSuite requires an application-level pattern using external IDs.
  4. Reconciliation runs on the caller side. Because you do not store payloads, you cannot reconcile centrally. Instead, the caller compares its own database to the ERP directly via read-through calls, using your integration layer as a stateless proxy.
// In-process retry with jitter - no payload persistence
async function writeWithRetry<T>(
  fn: () => Promise<T>,
  opts = { maxAttempts: 4, baseMs: 500, capMs: 8000 },
): Promise<T> {
  let lastErr: unknown
  for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
    try {
      return await fn()
    } catch (err) {
      lastErr = err
      if (!isRetryable(err) || attempt === opts.maxAttempts - 1) throw err
      const backoff = Math.min(opts.capMs, opts.baseMs * 2 ** attempt)
      const jitter = Math.random() * backoff * 0.3
      await new Promise(r => setTimeout(r, backoff + jitter))
    }
  }
  throw lastErr
}
 
function isRetryable(err: unknown): boolean {
  if (!(err instanceof Response)) return true // network error
  return err.status >= 500 || err.status === 429
}

For at-least-once semantics without persistence, rely on ERP-side idempotency. SAP's Idempotency-Key header lets you retry a POST safely - the server deduplicates based on the key. If the ERP does not support idempotency headers, use conditional writes (If-Match on ETags) or upsert semantics keyed by a business identifier (external ID, natural key). NetSuite's REST API supports externalId for exactly this reason.

Reconciliation without a local mirror: the caller runs periodic diffs by fetching filtered windows (lastModifiedDate > checkpoint) from the ERP through your proxy and comparing to their own records. Your job is to make sure the proxy is fast and predictable. You never see the reconciliation output.

Token Refresh and Secure Ephemeral Credential Handling

OAuth tokens are the one piece of state a zero-retention integration must persist. Access tokens expire (typically 30-60 minutes), so refresh tokens have to live on disk. The goal is to keep the credential footprint minimal and the plaintext lifetime as short as possible.

The pattern:

  1. Encrypt at rest with AES-GCM. Refresh tokens and access tokens live encrypted in a single row keyed by integrated account ID. The encryption key is managed by a KMS, not a config file secret. Rotate the KMS key at least annually.
  2. Decrypt only in memory. Plaintext tokens exist only for the duration of a single upstream request. They are never logged, never included in error messages, never returned in API responses.
  3. Refresh proactively. Schedule a refresh 60-180 seconds before the access token expires, randomized within the window to spread load across accounts. This avoids the "everyone refreshes at :00" thundering herd.
  4. Serialize concurrent refreshes. If two workers try to refresh the same token simultaneously, the second must wait for the first. Otherwise you burn refresh tokens - many providers rotate them on every refresh, and the older refresh token becomes invalid the moment the new one is issued.
sequenceDiagram
    participant W1 as Worker 1
    participant W2 as Worker 2
    participant Mutex as Per-Account Mutex
    participant Store as Encrypted Credential Store
    participant IdP as ERP Identity Provider

    W1->>Mutex: acquire(account_id)
    Mutex-->>W1: locked
    W2->>Mutex: acquire(account_id)
    Note over W2,Mutex: Blocks - awaits<br>same in-flight refresh
    W1->>Store: read encrypted token
    Store-->>W1: ciphertext
    W1->>W1: decrypt in memory
    W1->>IdP: POST /token (grant=refresh_token)
    IdP-->>W1: new access + refresh
    W1->>Store: write new encrypted token
    W1->>Mutex: release
    Mutex-->>W2: unblocked with W1's result
    Note over W2: Uses fresh token,<br>no duplicate refresh

Per-account serialization is essential. A single mutex keyed by the integrated account ID ensures only one refresh runs per account at a time. Concurrent callers wait on the same in-flight refresh and receive its result rather than issuing duplicate token exchanges. Two different accounts refresh in parallel, so the mutex does not become a global bottleneck.

Here is a minimal proactive refresh flow in code:

async function getAccessToken(accountId: string): Promise<string> {
  const account = await loadAccount(accountId) // metadata + ciphertext only
  const token = decryptToken(account.encryptedToken)
 
  // Refresh if within 30 seconds of expiry
  if (Date.now() + 30_000 >= token.expiresAt) {
    return await refreshMutex.run(accountId, async () => {
      // Re-read inside the lock - another worker may have refreshed already
      const fresh = decryptToken((await loadAccount(accountId)).encryptedToken)
      if (Date.now() + 30_000 < fresh.expiresAt) return fresh.accessToken
 
      const newToken = await exchangeRefreshToken(fresh.refreshToken)
      await saveEncryptedToken(accountId, newToken)
      scheduleProactiveRefresh(accountId, newToken.expiresAt)
      return newToken.accessToken
    })
  }
  return token.accessToken
}

Additional hardening:

  • Mark accounts for reauth on non-retryable errors. HTTP 401 or invalid_grant from the identity provider means the refresh token is revoked. Update the account status, fire a webhook so your customer knows, and stop retrying - no amount of backoff will fix a revoked grant.
  • Retry only on transient failures. Network errors and 5xx responses warrant a retry, ideally scheduled a few hours later, not immediately. 4xx responses do not.
  • Route refreshes through fixed egress IPs when required. Enterprise ERPs often restrict token endpoints to IP allowlists. Route refresh requests through a stable egress path so the allowlist does not have to change every deployment.
  • Never return tokens in API responses. Even to your own admin UI. Redact them at the serialization boundary.

Observability Without Persistence: Tracing and Logging Best Practices

Traditional observability practices leak data. Full request/response logging captures customer payloads. Distributed tracing spans often serialize payloads into span attributes. Debug dumps write entire response bodies to disk. A zero data retention integration must be observable while remaining data-free.

Guidelines:

  • Log shape, not content. Log { resource: 'invoice', operation: 'read', row_count: 2431, latency_ms: 812, status: 200 }. Never log { invoice_body: { ... } }.
  • Redact by allowlist, not by regex. Configure your tracer with an allowlist of span attributes. Anything not on the allowlist is dropped, not passed through. Regex-based PII scrubbing always misses something - a field renamed by a customer, an unexpected nested object, an unusual encoding.
  • Hash identifiers for correlation. When you need to trace a specific record through the pipeline, log a hash of its identifier, not the identifier itself. HMAC-SHA256 with a per-tenant salt gives you deterministic correlation without exposing values.
  • Sample errors, not payloads. For a 500 response from the ERP, log the HTTP status, the response headers you know are safe, and a categorized error code. Do not include the response body.
  • Structured error categories. Map upstream errors to a fixed vocabulary (upstream_timeout, rate_limited, auth_failed, validation_error, not_found). Categories are safe to store; raw error messages containing customer-specific detail are not.
  • Bound retention on operational logs. Even payload-free logs should have a TTL. 30-90 days is typical. Anything longer needs a documented business reason.
// Safe structured logging - never touches payload data
function logRequest(ctx: {
  accountId: string
  resource: string
  operation: 'read' | 'write' | 'delete'
  rowCount?: number
  latencyMs: number
  status: number
  category?: string
}) {
  console.log(JSON.stringify({
    ts: new Date().toISOString(),
    account_hash: hmacSha256(ctx.accountId, PER_TENANT_SALT),
    resource: ctx.resource,
    op: ctx.operation,
    rows: ctx.rowCount,
    latency_ms: ctx.latencyMs,
    status: ctx.status,
    category: ctx.category,
  }))
}

For deeper debugging, offer customers an opt-in temporary payload capture with a short TTL (e.g., 1 hour), gated by explicit consent per debugging session, ideally with a customer-side approval workflow. Never make payload logging a default state that requires opt-out.

Anti-Patterns That Create Hidden Persistence

The failure mode for zero data retention is rarely intentional storage. It is accidental storage. Every one of these has shipped in a "no data storage" integration and had to be ripped out:

  • Queue payloads as durable storage. If you enqueue a job with the customer payload embedded in the message, that payload lives in the queue's storage layer for hours or days. Queues are databases with a delivery model bolted on. Use them for control signals ("record X changed, go re-fetch it") but never for payloads.
  • Trace attributes containing bodies. Setting span.setAttribute('response_body', JSON.stringify(payload)) sends that payload to your APM vendor's servers, where it is retained per their policy. Assume every attribute you set is stored somewhere for at least 30 days.
  • Verbose error logs. catch (e) { console.error('Failed:', e, 'payload was:', payload) } writes the entire payload to your log aggregator. The exception category alone is usually sufficient.
  • Debug dumps to object storage. "Just write the failing payload to blob storage so we can debug it Monday" is the single most common way zero-retention promises get broken. If you must capture failure payloads, capture only synthetic reproductions, not real customer data.
  • Retry queues with payloads. Dead letter queues that store the original payload for later inspection are a persistence layer with a friendly name. Use control-plane retries (re-fetch and re-transform) rather than payload replay.
  • HTTP access logs with query strings. Your load balancer or reverse proxy probably logs full request URIs by default. If your API accepts filters as query parameters (?email=jane@customer.com), those are now in your access logs. Move filters to POST bodies or scrub URIs at ingress.
  • APM auto-instrumentation of database drivers. Some APM agents capture SQL statements with parameter values inlined. If your integration ever writes customer data to a working table (even briefly), the SQL trace will capture it. Configure the agent to redact parameter values.
  • Backup and snapshot systems. If you have an encrypted credential store, its backups are also encrypted - but the backups can persist for years past your stated retention window. Set backup TTLs deliberately, not just relying on defaults.
  • CDN or WAF request buffering. Some edge providers buffer request bodies for inspection. Confirm with your provider whether raw bodies are stored for any period, and for how long.
  • Container filesystems. If a process writes to /tmp or a mounted volume for any reason, that data can survive process restarts on some platforms. Treat every filesystem write as a persistence event.

Audit for these systematically. Run a data-flow diagram for every code path that touches a customer payload and ask: "Where does this byte sequence exist five minutes from now?" If the answer is anything other than "in memory of an already-terminated process," you have accidental persistence.

Operational Runbook: Incident Triage When Upstream APIs Fail

Zero data retention integrations have a specific failure mode: when the upstream fails, you cannot fall back to a local cache because there is no local cache. Runbooks matter more, not less.

Symptom: Upstream 5xx spike (>10% error rate over 5 minutes)

  1. Check the upstream provider's status page (NetSuite Status, SAP Trust Center, Dynamics Service Health).
  2. If the provider is degraded, enable a customer-facing banner acknowledging the issue and disable non-critical outbound writes.
  3. Confirm in-process retry logic is active. Retries should already be absorbing transient errors; if 5xx is escaping to callers, retries are broken.
  4. Do not enqueue failed requests for later replay. Return errors to callers with the correlation ID so they can decide whether to retry.

Symptom: OAuth refresh failure rate > 1%

  1. Check whether the failures are concentrated on a single tenant (revoked grant) or spread across tenants (identity provider outage).
  2. For single-tenant failures: mark the account for reauth, fire the customer webhook, stop retrying.
  3. For broad failures: confirm the identity provider status. If the IdP is down, active access tokens continue working until their natural expiry - do not force refreshes.
  4. Alert if the count of accounts in needs_reauth grows by more than 5% in an hour.

Symptom: Memory usage crosses 80% of ceiling

  1. Identify the fetcher responsible via per-integration memory metrics.
  2. Confirm the fetcher is streaming, not buffering. If it is buffering, switch to streaming and re-deploy.
  3. Reduce per-request page size for the affected resource temporarily.
  4. If the workload is truly larger than memory allows, refuse the request with a clear error rather than OOMing silently.

Symptom: Rate limit exhaustion (429 spike)

  1. Identify the affected tenant and integration.
  2. Confirm the rate limiter is respecting Retry-After headers from the upstream.
  3. If a single tenant is bursting, apply per-tenant throttling ahead of the upstream call.
  4. Escalate to the customer if they need to purchase additional API capacity from the ERP vendor.

Symptom: HMAC verification failures on inbound webhooks

  1. Confirm the shared secret hasn't rotated on the upstream side without your rotation.
  2. Check whether a proxy or WAF is mutating the request body (JSON reformatting is the usual culprit).
  3. Never disable verification to "work around" the issue. A failing verification is a signal, not a nuisance.

How Truto Normalizes CRM Complexities Without Custom Code

Everything above is correct, and every team that builds it from scratch ends up writing roughly the same code. Building out token buckets, keyset pagination, SOQL query builders, and dynamic schema discovery for just two CRMs will consume months of engineering time. Adding Microsoft Dynamics, Zoho, and Pipedrive will consume the rest of your year.

After enough integrations, you start to see the pattern: the differences between HubSpot and Salesforce are mostly data, not logic. The HTTP client, the pagination loop, the OAuth refresh, the response shape - those should be one piece of code parameterized by configuration.

Truto's architecture eliminates this burden entirely. Through our Unified API, we handle these complexities generically, allowing you to ship new API connectors as data-only operations.

Generic Execution Pipelines and JSONata

Truto operates on a declarative configuration model. There is zero integration-specific code in the Truto runtime. No if (provider === 'hubspot') statements exist in our proxy layer.

Instead, integration behavior is defined as data. We use JSONata expressions to map unified requests into provider-specific formats. For example, when you send a unified request to filter contacts by email, Truto's mapping engine automatically translates that into a HubSpot filterGroups array or a Salesforce SOQL WHERE clause.

flowchart LR
    A[Unified Request<br>GET /unified/crm/contacts] --> B[Generic Mapping Engine]
    B --> C{Integration<br>Config}
    C -->|HubSpot| D[filterGroups +<br>cursor pagination]
    C -->|Salesforce| E[SOQL WHERE +<br>OFFSET / nextRecordsUrl]
    D --> F[Unified Response<br>same shape]
    E --> F

Here is a simplified example of how Truto uses JSONata to normalize a Salesforce response, automatically identifying custom fields by their __c suffix:

response_mapping: >-
  response.{
    "id": Id,
    "first_name": FirstName,
    "last_name": LastName,
    "email_addresses": [{ "email": Email }],
    "custom_fields": $sift($, function($v, $k) { $k ~> /__c$/i and $boolean($v) })
  }

This single expression maps standard fields while dynamically isolating any custom fields the specific Salesforce tenant has created, packaging them neatly into a custom_fields object in the unified response.

Standardizing API Rate Limits

Every API returns rate limit information differently. HubSpot uses X-HubSpot-RateLimit-Remaining, while others use RateLimit-Remaining or bury the limits in the response body.

Truto normalizes upstream rate limit info into standardized headers per the IETF specification:

  • ratelimit-limit
  • ratelimit-remaining
  • ratelimit-reset
Info

Factual Note on Rate Limits: Truto is deliberately honest here. Truto does not swallow, retry, throttle, or apply backoff on rate limit errors automatically. When an upstream API returns an HTTP 429 or REQUEST_LIMIT_EXCEEDED, Truto passes that error cleanly to the caller along with the normalized IETF headers. The application owns the business context to decide whether to retry, drop, or degrade based on these predictable headers.

Dynamic Resource Routing

APIs often require different endpoints for different actions. For example, HubSpot has a standard endpoint for listing contacts, but requires a completely different search endpoint if you want to filter those contacts.

Truto supports dynamic resource routing natively. The platform evaluates the incoming request parameters and automatically routes the call to the correct upstream endpoint.

sequenceDiagram
    participant Client
    participant Truto Unified API
    participant Truto JSONata Engine
    participant Upstream CRM

    Client->>Truto Unified API: GET /unified/crm/contacts?email=test@example.com
    Truto Unified API->>Truto JSONata Engine: Evaluate query parameters
    Truto JSONata Engine-->>Truto Unified API: Route to Search Endpoint
    Truto Unified API->>Upstream CRM: POST /crm/v3/objects/contacts/search
    Upstream CRM-->>Truto Unified API: Raw Provider Response
    Truto Unified API->>Truto JSONata Engine: Apply Response Mapping
    Truto JSONata Engine-->>Truto Unified API: Normalized Unified JSON
    Truto Unified API-->>Client: 200 OK (Unified Schema)

This architecture ensures that your engineers write code against one predictable REST API, while Truto handles the chaotic reality of third-party SaaS platforms in the background.

Unified Webhooks for Bidirectional Sync

The inbound webhook handling described in Recipe 4 is exactly the kind of per-provider complexity that compounds across integrations. HubSpot uses X-HubSpot-Signature-v3 with HMAC SHA-256 over a specific concatenation. Salesforce uses its own verification scheme. Every CRM webhook has a different payload shape, different event type naming, and different retry behavior.

Truto's unified webhook layer normalizes all of this. Truto supports two inbound webhook ingestion patterns - account-specific and environment-integration fan-out - and uses JSONata-based configuration for provider-specific event normalization. The result is that your app receives a consistent event payload regardless of whether the source is HubSpot, Salesforce, or any other CRM.

Outbound delivery to your endpoint uses queue-backed processing with signed payloads. Truto signs every outbound webhook with HMAC-SHA256 and sends the signature in the X-Truto-Signature header, so your verification code is the same regardless of the upstream provider.

Go-Live Checklist

Before flipping your bidirectional CRM sync to production, walk through this list:

Authentication & Security

  • OAuth 2.0 flow tested with token refresh (confirm your app refreshes tokens before expiry)
  • Webhook endpoint is HTTPS-only
  • v3 HMAC signature verification active and rejecting stale timestamps (> 5 minutes)
  • Webhook secret stored encrypted at rest

Rate Limiting

  • Token bucket limiter in place for general API calls (110/10s for public apps, 190/10s for private Pro/Enterprise)
  • Separate rate limiter for CRM Search API (~4-5 req/s)
  • Retry-After header respected on 429 responses
  • Jitter added to retry delays

Sync Logic

  • Field ownership matrix documented and implemented for every synced field
  • Echo/loop prevention via outbound fingerprints verified with a live round-trip test
  • Deletion handling strategy configured (hard delete, soft delete, or archive)
  • Idempotency store in place for inbound webhook eventId deduplication
  • Custom properties discovered dynamically (not hardcoded)

Pagination & Bulk

  • Cursor-based pagination handling tested beyond 10,000 records
  • Batch endpoints used for bulk operations (up to 100 records per batch call)
  • Bulk API 2.0 (Salesforce) tested for backfills over 5,000 records

Monitoring

  • 429 rate alerting configured (> 5% threshold)
  • Webhook inbound silence alert (0 events for 10+ minutes)
  • HMAC verification failure alerts active
  • OAuth token refresh failure alerts active
  • Reconciliation job scheduled (every 4-8 hours)
  • Drift count baseline established

Operational Readiness

  • Dead letter queue configured for failed webhook deliveries
  • Manual reconciliation / full-resync trigger available
  • Runbook documenting how to pause sync, replay events, and investigate drift

Strategic Wrap-Up and Next Steps

Building native CRM integrations requires far more than parsing a JSON response. To survive production environments, your architecture must account for strict burst rate limits, complex pagination models, and the reality of highly customized tenant schemas.

If your team is shipping a Salesforce or HubSpot integration this quarter, the priorities in order are:

  1. Implement a token bucket and Retry-After-aware client for HubSpot before you write a single mapper.
  2. Use Bulk API 2.0 for any Salesforce backfill over a few thousand records.
  3. Discover custom fields dynamically - do not hardcode field lists.
  4. Treat OAuth refresh as a separate failure domain with its own alerting.
  5. Decide build-vs-buy explicitly. If you are heading toward 5+ CRMs, the cost curve of maintaining per-vendor code paths gets brutal fast.

FAQ

What is the HubSpot API rate limit for public apps in 2026?
Public OAuth apps are limited to 110 requests per 10 seconds per installed account, and the CRM Search API has a separate cap of 5 requests per second shared across all object types. Daily quotas range from 250,000 (Free/Starter) to 1,000,000 (Enterprise).
How do I avoid hitting Salesforce governor limits in an integration?
Use Bulk API 2.0 for any operation over a few thousand records (each 10k-record batch counts as one call against the daily limit), keep synchronous requests under 20 seconds to avoid the concurrency cap, and use keyset pagination instead of OFFSET for large queries.
How should I handle custom fields in Salesforce and HubSpot?
Discover them at runtime. Call `/sobjects/Contact/describe` for Salesforce (filter matching the `__c` suffix) and `/crm/v3/properties/contacts` for HubSpot. Refresh on a TTL (24 hours works) and explicitly request them in your API calls rather than hardcoding fields.
Should I retry on a 429 from HubSpot inside my application or rely on my unified API?
Retries belong in your application, not in the integration layer. The application owns the business context to decide whether to retry, drop, or degrade. A platform like Truto passes 429s through cleanly and normalizes rate-limit headers to IETF standards, but does not retry on your behalf.

More from our Blog