Engineering
Engineering

Building the Zero CRM integration: a sync that refuses to guess

Zac Sheffer · 2026.9.10 · 8 min read

We run our go-to-market on Zero, an AI-native CRM (that story is here). For months, our referral pipeline lived in Introzy and our deal pipeline lived in Zero, and the bridge was a person with two tabs open. We build CRM integrations for a living, so eventually we built our own.

This post covers the design decisions, the code patterns, and where we chose to be boring on purpose.

What transferred from HubSpot and Salesforce

Zero was our third CRM integration, after HubSpot and Salesforce. The CRM-agnostic patterns transferred without modification:

  1. Enqueue-time dependency ordering
  2. Idempotent job IDs (same principle Stripe codified in their idempotent requests documentation)
  3. Update coalescing
  4. The match ladder

These are properties of the sync problem, not any vendor's API.

What did not transfer: anything that assumed a conventional CRM data model. HubSpot and Salesforce have deep object hierarchies with explicit association types. You create a deal, associate it with a company and contacts, and the CRM enforces referential integrity between them. Zero is flatter. Its deal record is closer to a row on a pipeline board, and contact-to-deal attachment is less structured. With Zero, we enforce more of that integrity ourselves at enqueue time.

The other gap was API maturity. HubSpot and Salesforce have stable, exhaustively documented APIs. (HubSpot publishes detailed API usage guidelines covering rate limits, burst caps, and per-account quotas.) Zero's API is newer and evolving faster. Response shapes occasionally differ from documentation. Not a complaint; it is the reality of integrating with a product on a fast release cycle. It meant our response normalizer earned its keep earlier and harder on this integration.

Mapping a referral to a CRM deal

An Introzy referral is a three-party object: a seeker, a provider, and the contact the introduction happened through. A Zero deal is a CRM-shaped object: it hangs off a company, carries a pipeline stage, and has contacts attached.

Flattening three parties into company-plus-stage means choosing which party the deal belongs to. We chose: the Zero company comes from the deal's primary contact, not the seeker or provider. The CRM record should center on the relationship your team actually works. Seeker and provider still travel with the deal as fields, but the record hangs where a salesperson expects to find it.

Stage mapping

Stage mapping is where CRM syncs quietly rot. Two pipelines, two vocabularies, and some stages with no counterpart on the other side. Most syncs guess: nearest stage, first stage, a default. Every guess is a small lie in someone else's system of record.

Zero made this harder than HubSpot or Salesforce. HubSpot deal stages are essentially dropdown labels. You can move a deal from any stage to any other without the CRM objecting. Zero's pipeline has implicit directionality. Moving a deal backward is possible but semantically loaded, and Zero-side automations may fire differently depending on whether a stage change is forward or backward.

So our stage mapping could not just pair names. It needed to understand ordering on both sides and refuse mappings that produce nonsensical transitions. Mapping "Negotiation" on our side to "Discovery" on Zero's side tells Zero the deal regressed, which can trigger re-engagement automations the customer did not intend.

We fail closed. Pipelines are paired at the org level, stages are mapped explicitly, and an unmapped stage refuses to sync:

export function resolveZeroCrmStage(
  pipelineStageId: string | null | undefined,
  stageMappings: unknown
): ZeroCrmStageResolution {
  if (!pipelineStageId) return { reason: 'missing_stage_mapping' }

  const parsed = stageMappingsSchema.safeParse(stageMappings)
  if (!parsed.success) return { reason: 'missing_stage_mapping' }

  const match = parsed.data.maps.find(m => m.introzyStageId === pipelineStageId)
  return match ? { zeroStageId: match.zeroStageId } : { reason: 'missing_stage_mapping' }
}

A manual push with an unmapped stage returns a structured reason the UI can show. An automatic sync skips. Nothing lands in Zero that a human did not explicitly map a path for.

Dependency ordering at enqueue time

A deal cannot land in Zero before its company exists there, and a contact needs its company too. The naive approach lets jobs fail and retry until dependencies happen to resolve. That works until it does not.

We enforce ordering at enqueue: company first, then contacts, then the deal. Each stage only enqueues when its parent has a persisted Zero ID. The queue never contains a job doomed on arrival. Retries should mean transient failure, not "my dependency hasn't been created yet."

Idempotency and coalescing

Sync triggers fire from ordinary product activity, and ordinary activity is bursty. Someone edits a company five times in a minute. That should be one sync, not five.

Every outbound job gets a stable ID derived from the object it syncs, so the queue itself is the deduplication layer. As the BullMQ documentation on job IDs describes, adding a job with an ID that already exists will not create a duplicate:

const jobId =
  data.objectType === 'contact'
    ? `zero-outbound-contact-${data.contactUserId}`
    : data.objectType === 'deal'
      ? `zero-outbound-deal-${data.dealId}`
      : `zero-outbound-${data.companyId}`

The enqueue helper handles three states for an existing job:

  1. Terminal (completed or failed): remove it and add fresh.
  2. In flight (waiting, delayed, or active): merge new payload into the existing job with updateData, so five edits coalesce into one job carrying the latest state.
  3. ID collision across object types: bug worth throwing on, not absorbing.

One more filter: field allowlists. A company has many columns but only a handful sync. An edit that touches none of the synced fields never enqueues at all:

export const ZERO_CRM_OUTBOUND_SYNC_FIELDS = [
  'name',
  'website',
  'description',
  'linkedinUrl',
  'isSolutionProvider',
] as const

export function shouldSyncZeroCrmCompanyFields(changed: readonly string[]): boolean {
  return changed.some(key => (ZERO_CRM_OUTBOUND_SYNC_FIELDS as readonly string[]).includes(key))
}

Match ladders, never merges

When a company syncs for the first time, it might already exist in Zero. Matching runs as a ladder, most trustworthy rung first: a previously persisted Zero ID, then our external ID stamped on the Zero record, then domain for companies and email for contacts. (Salesforce's duplicate management documentation describes a similar tiered approach.)

Two rules keep the ladder safe:

  1. Ambiguous matches become permanent skips, not retries. A match that was ambiguous this run will be ambiguous next run. Retrying a judgment call is asking the same question louder.
  2. We never merge records and never touch Zero's `custom` fields. The customer's CRM has its own data model and history. An integration is a guest. Guests do not rearrange the furniture.

Treating the platform's output as untrusted input

The sync executes through flows on a managed integration platform, which also holds the credentials, so workspace API keys never live in our own tables. The trade: responses come back through more layers than a direct API call, and their shape can vary by platform version and flow configuration.

The normalizer treats every response as hostile:

function normalizeFlowResponse(raw: unknown): NormalizedResult {
  const envelope = unwrapEnvelope(raw)      // strip platform wrapper
  const rows = findArray(envelope,           // accept data, workspaces, or records key
    ['data', 'workspaces', 'records'])
  return rows.map(row => ({
    externalId: expectString(row, 'id'),     // throw typed error if missing/wrong type
    displayName: expectString(row, 'name'),
    updatedAt: expectISODate(row, 'updated_at'),
  }))
}

When the platform changes a response shape, the typed error names the field and the expected type. Not undefined is not a function three layers later.

Failure reporting follows the same principle: sync failures surface to the UI only on the final retry attempt. Intermediate retries are the queue doing its job. A status indicator that flaps on every transient error trains people to ignore it.

Outbound first, on purpose

We built the outbound path first: Introzy pushes companies, contacts, and deals into Zero. During a pilot, one direction of writes means one source of truth. Every record in Zero traces back to an Introzy record that explains it. Debugging a one-way sync means reading the log. Debugging a two-way sync means asking "who wrote this and why."

Inbound (Zero-side changes flowing back) is next now that the outbound path has soaked. A sync direction is a liability. Take them on one at a time.

What we would tell you if you are building one

  1. Decide early which party's record a multi-party object hangs on in the CRM. It shapes everything downstream.
  2. Enforce dependency order at enqueue. Retries should mean transient failure, nothing else.
  3. Fail closed on stage mapping. A skip with a reason beats a guess.
  4. Stable job IDs plus coalescing turn bursty edits into a calm queue.
  5. Match with a ladder, skip on ambiguity, never merge someone else's records.
  6. Normalize and validate everything crossing the platform boundary. Zod at the seam, typed errors with names.

The integration is live: Introzy + Zero, alongside the rest of the catalog. For the operator's view of the same story, start here.

We ship in public. Come build with us.

Introzy is free to start. No card required.