CRM automation has a failure mode that ordinary software does not. When a web app breaks, users complain within minutes. When a lead-routing workflow breaks, everything looks normal — the forms still submit, the dashboards still render — and the only symptom is that deals stop appearing. By the time someone asks why last month was quiet, the leads are unrecoverable.
So the design goal is not "does it work on the happy path". It is "will we know within an hour when it stops".
Model the data before automating anything
Most CRM messes are data-model messes wearing a workflow costume. Before a single automation is built, three things need answering:
- 01What is the unique key for a person, and for a company? Email seems obvious until someone changes jobs, or a shared
info@address maps to four contacts. Decide, write it down, enforce it in code. - 02What are the lifecycle stages, and what moves something between them? If two people would describe "qualified" differently, every report built on it is fiction.
- 03Which system owns which field? When the product database and the CRM both think they own
plan_tier, they will disagree, and whichever wrote last wins silently.
Make every write idempotent
Webhooks retry. Users double-click. Networks time out after the write succeeded but before the response arrives. Any of these will create a duplicate contact or a second deal unless the operation is designed to be safe to repeat.
// Upsert on a stable natural key rather than blind-creating.
async function recordInquiry(lead: Lead) {
const idempotencyKey = sha256(`${lead.email}:${lead.formId}:${lead.submittedAt}`);
if (await seen(idempotencyKey)) return; // retry of a call that already landed
const contact = await hubspot.crm.contacts.basicApi
.upsert({
idProperty: 'email', // natural key, not an internal id
properties: {
email: lead.email,
firstname: lead.firstName,
company: lead.company,
dazvix_budget_band: lead.budget,
},
});
await createDealOnce(contact.id, lead);
await remember(idempotencyKey);
}The same rule applies to deals: check for an open deal on that contact for that form before creating another. It is a single extra query and it prevents the most common pipeline-hygiene complaint there is.
Never let the CRM be in the request path
If a form submission writes to the CRM synchronously, then the CRM having a slow afternoon becomes your form having a slow afternoon — and a rate limit becomes a lost lead. Accept the submission, persist it, respond, and process asynchronously.
export async function POST(req: Request) {
const lead = LeadSchema.parse(await req.json());
// 1. Durable first. If everything downstream fails, the lead still exists.
const id = await db.leads.insert({ ...lead, status: 'pending' });
// 2. Tell the user immediately.
queue.enqueue('crm:sync', { leadId: id });
return Response.json({ ok: true });
}This one change converts a whole class of silent data loss into a retryable queue job. The lead is captured the moment it arrives; whether HubSpot accepted it is a separate, observable concern.
Retries, backoff and a dead-letter queue
Transient failures should retry with exponential backoff and jitter. Permanent failures — a validation error, a revoked token — should not retry at all; they should go to a dead-letter queue that a human looks at.
The distinction matters. Retrying a 400 forever burns your rate limit and buries the real error. The dead-letter queue is the thing that turns "leads silently vanished" into "nine items need attention", which is a completely different Monday.
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
async function withRetry<T>(fn: () => Promise<T>, attempt = 0): Promise<T> {
try {
return await fn();
} catch (err) {
const status = (err as ApiError).status;
// 4xx (except 408/429) means the request itself is wrong — retrying
// will fail identically and hide the cause.
if (!RETRYABLE.has(status) || attempt >= 5) {
await deadLetter.push({ err, attempt });
throw err;
}
const backoff = 2 ** attempt * 1000;
const jitter = Math.random() * 500; // avoid a retry stampede
await sleep(backoff + jitter);
return withRetry(fn, attempt + 1);
}
}Alert on absence, not just on errors
This is the part almost everyone skips, and it is the part that catches the failure described at the top. Error alerting tells you when something threw. It cannot tell you when a workflow quietly stopped firing — because nothing threw.
So alert on the absence of expected activity:
- No leads created in 24 hours, when the weekday baseline is fifteen.
- A lead sitting in
pendingfor more than an hour. - The dead-letter queue non-empty for more than a day.
- Deal-to-contact ratio drifting outside its normal band.
- A scheduled sync that has not checked in — a heartbeat, so a job that never starts is as loud as one that fails.
These are cheap to implement and they are the difference between a bad hour and a bad quarter.
Keep it debuggable by the people who own it
Operations teams, not engineers, live with these systems. Log every sync with the lead id, the action, the outcome and the request id from the provider, and put it somewhere a non-engineer can search. When sales asks why a particular lead never got routed, the answer should take a minute and not require a developer.
Equally: no automation should be un-runnable by hand. A "resync this lead" button that a support person can press turns a large class of incidents into a non-event.
Automation that fails loudly is a bug. Automation that fails silently is a data-loss incident you have not been told about yet.