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.
Zero was our third CRM integration, after HubSpot and Salesforce. The CRM-agnostic patterns transferred without modification:
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.
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 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.
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."
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:
updateData, so five edits coalesce into one job carrying the latest state.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))
}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:
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.
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.
The integration is live: Introzy + Zero, alongside the rest of the catalog. For the operator's view of the same story, start here.
Introzy is free to start. No card required.