An n8n Workflow Example Built to Survive Retries: The Follow-Up Machine Architecture
Part 2 of 6 in the series The Follow-Up Machine
- Overview, Automated CRM Follow-Up That Tells You When It Breaks
- Architecture, An n8n Workflow Example Built to Survive Retries: The Follow-Up Machine Architecture
- Register, EspoCRM Webhooks in n8n: The Event Name, the Signature and the Stage That Did Not Exist
- Evaluate, An Hourly n8n Workflow That Does Not Overlap Itself: Locks, Merge Barriers and Quiet Runs
- Enrich, AI Summaries of CRM Emails with n8n and Mistral: The Prompt, the Parser and the Comma
- Alerts, n8n Error Workflows That Actually Alert: Deduplication, Stale Locks and the Friday Email
All 6 parts
- Overview, Automated CRM Follow-Up That Tells You When It Breaks
- Architecture, An n8n Workflow Example Built to Survive Retries: The Follow-Up Machine Architecture
- Register, EspoCRM Webhooks in n8n: The Event Name, the Signature and the Stage That Did Not Exist
- Evaluate, An Hourly n8n Workflow That Does Not Overlap Itself: Locks, Merge Barriers and Quiet Runs
- Enrich, AI Summaries of CRM Emails with n8n and Mistral: The Prompt, the Parser and the Comma
- Alerts, n8n Error Workflows That Actually Alert: Deduplication, Stale Locks and the Friday Email
This is part 2 of a six-part series on the follow-up machine: a set of n8n workflows that watches a CRM for deals that have gone quiet and creates follow-up tasks. Part 1 explains what it does and why. The remaining parts explain how, with the real code and the real mistakes.
Five workflows that never call each other
The system is five n8n workflows:
| Workflow | Trigger | Job |
|---|---|---|
| Followup 1: Register | CRM webhook | Start watching a deal that enters Proposal or Negotiation |
| Followup 2: Evaluate | Hourly, weekdays | Reconcile with the CRM, decide what is due, create tasks |
| Followup 3: Report | Two schedules | Morning list and Friday digest |
| Followup 4: Enrich | Hourly, at :30 | Summarise recent emails into two lines per deal |
| Followup 0: Alerts | Error trigger | Log failures, send deduplicated alerts |
They share one Postgres database and nothing else. No workflow passes data to another except through a table, and none of them starts another. What n8n itself does is separate: when a run fails, n8n starts the error workflow, and that is the one path on which one workflow’s failure invokes another. The rule behind the rest: adding a workflow must not break the existing ones. Enrich was added a day after the other four were designed. It runs without them knowing it exists. Showing its output in tasks and digests did take small changes to Evaluate and Report, but nothing broke while it was missing.
Three systems, three different jobs
The first design decision was which system owns what.
- EspoCRM is the authoritative source. Deals, stages and activities live there. The machine only reads it. Nothing in version 1 writes to the CRM, so a bug in the machine cannot corrupt a deal.
- Postgres holds orchestration state only. Which deals are being watched, which follow-ups are due, which have fired. If this database were lost, the business data in the CRM would survive. The follow-up history and the run log would not.
- ClickUp presents tasks, it is not a record of anything. A task tells me to act. The tasks are of course stored in ClickUp, but neither business data nor workflow state is kept there.
That split decides what happens when something fails.
Losing Postgres costs reminders, not CRM data. Losing ClickUp costs a notification, not state.
The data model
Five tables. The two that matter:
create table watches (
id bigserial primary key,
subject_type text not null, -- 'opportunity' | 'contact' (future)
subject_id text not null, -- CRM record id
subject_name text not null, -- denormalised, for digests when the CRM is down
cadence text not null default 'default',
registered_at timestamptz not null default now(),
last_reset_at timestamptz,
status text not null default 'open',
closed_reason text -- won | lost | exhausted | subject_gone | out_of_scope
-- trimmed
);
create unique index watches_one_open_per_subject
on watches (subject_type, subject_id)
where status = 'open';
create table watch_steps (
id bigserial primary key,
watch_id bigint not null references watches(id) on delete cascade,
step_no int not null,
due_on date not null,
fired_at timestamptz,
skipped_at timestamptz,
clickup_task_id text,
unique (watch_id, step_no)
);
The others are cadences (a follow-up schedule as an array of business-day offsets, {5,12,21} by default), holidays, and run_log, which records every run of every workflow.
Two details are there on purpose. subject_name is copied from the CRM so the Friday digest stays readable when the CRM is unreachable. And subject_type exists although only opportunities are watched today, because adding contacts later should be a change to one workflow, not a migration.
Rule 1: running twice must equal running once
CRMs retry webhooks. Schedules overlap. Networks drop halfway through a request. So every workflow is written on the assumption that it will run twice with the same input.
For registration, the partial unique index above does the work. A deal can have at most one open watch. The insert simply does nothing when one exists:
insert into watches (subject_type, subject_id, subject_url, subject_name, account_name, context, cadence)
select j ->> 'subject_type', j ->> 'subject_id', j ->> 'subject_url', j ->> 'subject_name',
nullif(j ->> 'account_name', ''), nullif(j ->> 'context', ''), j ->> 'cadence'
from p
on conflict (subject_type, subject_id) where status = 'open' do nothing
returning id
do nothing, not do update. A retried webhook must not reset a cadence that is already running.
For task creation in ClickUp there is no database constraint to lean on, so Evaluate checks ClickUp for an existing task before creating one. Part 4 covers that.
Rule 2: one run at a time
Two runs of Evaluate at the same moment would both see the same due follow-up and both create a task. The guard is a claim row in run_log:
insert into run_log (workflow, run_id, started_at, status)
select 'wf2', gen_random_uuid(), now(), 'running'
where not exists (
select 1 from run_log
where workflow = 'wf2'
and status = 'running'
and started_at > now() - interval '55 minutes'
)
returning id as run_log_id, run_id;
If the insert returns a row, this run owns the work. If it returns nothing, another run is live and this one ends quietly. The 55-minute window is the backstop for a run that crashed without releasing its claim: shorter than the hourly schedule, longer than any real run.
Trap. That window also hid a bug, because an hourly schedule clears a 55-minute window with five minutes to spare. More on that in part 4.
Rule 3: the webhook is an optimisation
Register reacts to a CRM webhook within seconds. But webhooks get lost: the CRM can fail to send, n8n can be restarting, Postgres can be down after the webhook was already acknowledged.
So every Evaluate run starts with a reconciliation pass. It asks the CRM for every deal currently in an in-scope stage and compares that list with the open watches. Any deal without a watch gets one. That makes a lost webhook harmless within the hour, and it means the whole system works with no webhooks at all, only slower.
This pass is the most important design decision in the machine and the most tempting thing to remove as an “optimisation”. It stays.
Rule 4: Postgres failure is fatal, third-party failure is skippable
| A node that touches | On final failure |
|---|---|
| Postgres | Fail the run. State is unknown, do not continue. |
| The CRM API | Skip that item, continue, mark the run partial |
| The ClickUp API | Skip that item, continue, mark the run partial |
| Outlook | Fail the run and alert |
The reasoning is short. If the machine cannot record what it did, it must stop, because the next run would act on a false picture. If one deal cannot be read, the others still can, and that deal is retried next hour.
Rule 5: dry-run blocks external writes only
Every write path has a dry-run mode, but it does not block everything. DRY_RUN=true stops ClickUp tasks and emails. It does not stop Postgres writes. A dry run registers watches, marks due follow-up steps as processed (with the string 'dry-run' in place of a task ID) and writes run_log, exactly as production would.
Dry run. Otherwise a dry run tests a different pipeline from the one that goes live. The model call in Enrich is not gated by dry-run at all: it still goes to Mistral’s API, and it still costs money there.
Where the logic lives
The rules that are worth testing, business-day arithmetic, the routing decision, the digest text, the prompt and parser for the AI step, live in TypeScript with vitest tests, outside n8n. A build script bundles each entry point into a snippet that is pasted into a Code node. SQL for every Postgres node lives in its own file.
Two platform details from n8n 2.x cost real time here:
- Code nodes run in an external task runner, and it cannot read
$env. Every secret, base URL or flag a Code node needs is injected by a Set node in front of it, which runs in the main process. On top of that, n8n 2.x blocks$envin expressions by default.N8N_BLOCK_ENV_ACCESS_IN_NODE=falseturns that off. - The task runner sandbox silently ignores
Object.defineProperty. esbuild’s IIFE and CommonJS output defines its exports with exactly that, so the first live run died with__node.run is not a function. Building as ESM, with no export helpers, fixed it.
What this architecture does not solve
It does not prevent a system that runs without errors and does the wrong thing. The worst bug in the build was a stage filter that looked for the wrong stage name. Every rule above held, every run succeeded, and the machine ignored every deal at proposal stage for five days. Part 3 tells that story.
Next: Part 3, Register.