The work on this page is covered by a non-disclosure agreement with Smoothops Consulting and their end client. The client is not named and not described. No hostname, workflow id, database id or record count belonging to them appears anywhere on this page. What is described is the architecture and the rules it runs on.

n8n AutomationCRM SyncDistributed Locking

How Abhiman Labs built a six workflow sync for a Smoothops Consulting client

Built for a Smoothops Consulting client · Abhiman Labs · Ongoing

Attio decides what is true. Most of the code exists so that nothing gets written twice.

6
workflows in production
250+
nodes across them
13
Redis nodes on the lock alone
100+
concurrent webhooks absorbed
Ongoing
still their backend specialist
Built withn8nAttioGranolaNotionRedisPostgres

The Problem

Smoothops Consulting builds Notion workspaces. Tim Jeffries brought Abhiman Labs in as the backend automation specialist for a client build where three systems had to hold the same state: Attio for the CRM, Granola for the meeting recordings, Notion for the work.

The short path was Granola straight into Notion. That path was rejected. Attio is the source of truth, so every meeting has to resolve to a canonical person and a canonical company before it reaches Notion, and everything flows Granola to Attio to Notion.

That one decision sets the rest of the build. Six workflows now sit on that path, and most of what they do is decide whether a record already exists before writing anything. They keep the numbering they carry in production, which is why there is no workflow 4 below. That one handles deletions and is out of scope here.

Three systems, one state

Granola supplies the recordings. Attio resolves who and what they belong to. Notion holds the surface people work on.

GranolaMeeting recorderTwo API keys. Neither is a superset of the other.
3bNotes onto the meeting record
AttioCRM, source of truthAn empty field here clears the Notion side.
1 · 2 · 3cPeople, companies, meeting pages5A nudge, on a button
NotionThe workspaceOne writer per database. Meeting pages have one author.
3aNew and edited notes become queue rows
No meeting content reaches Notion until Attio has resolved the person and the company behind it. The queue is a ledger of what exists, and it is the one thing Granola writes into Notion directly. The meeting content itself never takes that path, and that path was rejected.
Attio wins
An empty field in Attio clears the Notion side.
One writer per database
Meeting pages have exactly one author workflow. That rule is what keeps duplicates out.
Ids first
Records are tied together by id. A name or a domain only ever finds a page the first time, and a page found that way carrying a different id is refused.
Property names are the wiring
Renaming a Notion property throws no error. The sync just stops finding pages, so the schema is checked before every run.

What it runs on

Self hosted n8n in queue mode. Postgres holds execution state, Redis holds the job queue, and workers pull from it. A single process instance would run these one after another and could not carry the load.

That is what lets a burst land safely. If 100 webhooks fire at the same moment, every one becomes a queued job instead of being handled inline, so none is dropped on the floor. Where several of them touch the same record they are taken first in, first out, and the order they arrived in is the order they are applied.

Webhooks arriving together
Redis queuefirst in, first out
Workers pulling
Nothing is handled inline. A delivery becomes a job the moment it lands, so a burst costs queue depth instead of dropped requests. Two jobs touching the same record are taken in the order they arrived.

Postgres is doing real work in that sentence. n8n's default database takes a lock on the whole file to write, so concurrent executions end up queueing behind the execution log itself. Postgres writes many at once, which is the difference between workers that scale and workers that wait on each other.

Redis does a second job here. The application level locks live in it, and that is what stops two of those queued jobs from being inside the same record at the same time. It is the next section.

Two edits, one person, one lock

The mutex is INCR on a lock key with a 30 second TTL. It returns 1 to the winner and a higher number to everyone else.

INCR on the lock key30 second TTL
Run Areturns 1
Acquire LockClaim Lock OwnershipRelease Lock
Run Breturns 2
Acquire LockRetry Acquire LockClaim Lock OwnershipRelease Lock
takeAcquire Lock · Retry Acquire Lock · Stamp Lock TTL · Claim Lock Ownership · Map Exec To Lock
checkRead Lock Owner
releaseRelease Lock · Release Lock Owner · Release Exec Map
stealRead Stuck Lock Owner · Claim Steal · Force Clear Lock · Force Clear Owner
Both runs hit the same key. A gets 1 and takes the lock. B gets 2, backs off, and passes once A has released. INCR is atomic, so there is no check then set gap for a second run to slip through.

A second key holds a random ownership token, also 30 seconds, and only the run whose token matches may release. Without it, a slow run could release a lock that a later run now legitimately holds.

A third key maps the n8n execution id to the lock at 90 seconds, deliberately outliving the lock itself, so a crashed run can still be traced afterwards. Crash recovery is contested on purpose: a separate INCR on a steal key that carries its own short expiry, so when several runs all notice the same stuck lock exactly one of them wins the right to force clear it, and the next stuck lock gets a fresh contest.

Signatures, and deliveries that arrive twice

The webhook verifies an HMAC signature over the raw body. Unsigned traffic, which any public URL gets constantly, is dropped silently. A wrong signature stops the run loudly.

Idempotency is a separate problem from locking, and it has a separate mechanism. Repeat deliveries are dropped through an n8n Data Table keyed on the idempotency header the CRM sends: read, then write if absent. That pair is only safe because it happens inside the lock. A dedicated housekeeping workflow prunes it.

No signaturedropped, no error raised
Wrong signaturerun stops, loudly
Key seen beforeswallowed by the Data Table
Signed, new keyruns
Two gates doing two jobs. The signature decides whether a delivery is real. The idempotency key decides whether it has already been handled. Only the fourth case gets to do any work.
Workflow 1

Attio to Notion People Sync

Webhook triggered on a person being created or on any attribute being edited. It re-reads the person from Attio rather than trusting the webhook payload, because Attio keeps superseded values in the same list and the newest is not always first. It also recognises the link it wrote back to Attio and stops there, which is what prevents an endless loop.

Attio to Notion People Sync60+ nodes
  1. Webhook
  2. Compute HMAC
  3. Signature Matches?
  4. Acquire Lock
  5. Wait
  6. Retry Acquire Lock
  7. Seen This Delivery?
  8. Re-read Person
  9. Route On Change
  10. Resolve Company
  11. Find Person Page
  12. Pick Duplicate Keeper
  13. Release Lock
TriggerCodeHTTP RequestIFSwitchRedisCryptoData TableWait
Company lookup
By id, then by website domain or exact name. If no page exists, one is created.
Person lookup
By contact id, or by any of their email addresses.
Email on the wrong page
An email matching a page that carries a different contact id is rejected. That page is someone else's.
Failed company lookup
The existing relation is left alone, so one bad lookup never wipes a correct relation.
Malformed email
Written blank rather than failing the whole page.
More than 100 pages on one id
Stops and asks a person.
Duplicate resolution
Keeps the page carrying hand made links, then the oldest carrying the id, then the oldest. Losers go to the Notion trash, recoverable. The ordering is total, right down to comparing page ids, so two runs happening at the same moment pick the same keeper without talking to each other.
Workflow 2

Attio to Notion Company Sync

The company sync is narrow on purpose. Its webhook fires only on Name, Domains or Description, so editing any other field on an Attio company does not reach Notion at all.

Attio to Notion Company Sync40+ nodes
  1. Webhook
  2. Which Field Changed
  3. Stored Notion Link?
  4. Update Linked Page
  5. Search By Domain
  6. Search By Name
  7. Company Id Matches?
  8. Halt On Conflict
  9. Write Company Page
Shortcut
When Attio already stores the Notion link, that page is updated directly with no search.
Fallback
If the shortcut fails, for instance because the page was deleted, it searches instead.
Match order
A domain match beats a name match.
Contested page
A page found by domain or name that already carries a different company id is never taken over. The run stops. Two Attio records are claiming one company.
Workflow 3a

Granola to Notion Sync Queue

Scheduled, with no webhook. It lists notes from two separate recorder API keys, because neither key is a superset of the other, and each note is then fetched with a key proven to have access to it.

Granola to Notion Sync Queue40+ nodes
  1. Schedule Trigger
  2. List Notes Key A
  3. List Notes Key B
  4. Diff Fingerprints
  5. Fetch Note By Id
  6. Group Same Call
  7. Override Set?
  8. Ensure Queue Column
  9. Value Actually Differs?
  10. Write Queue Row
The list under reports
The recorder's list endpoint claims to be complete and is not. So the queue is the durable ledger, the list is used only to discover new ids, and a note missing from the list is still fetched by its id.
Only real changes
A stored fingerprint decides what changed, so only genuinely new or edited notes are read in full.
Same call, or not
Same calendar event within six hours is certain. Different owners within five minutes with overlapping outside attendees is a merge. Different owners with no calendar event within ninety seconds merges but is flagged for review. Anything else is kept apart and the near miss is written down.
Human override
A person settles it permanently with an override field. The algorithm re-decides from scratch every run and would otherwise undo the correction.
Queue column
Created if missing, never rewritten if it already exists. A rewrite used to wipe options a person had added.
Quiet runs
It writes only when a stored value actually differs. A converged run writes nothing at all.
Workflow 3b

Granola to Attio Notes

Scheduled, plus an on demand button. It pages through Attio meetings with a cursor, and if that pagination is cut short it says so rather than guessing. A note is matched to a meeting on the exact start instant, with five minutes of tolerance.

Granola to Attio Notes50+ nodes
  1. Schedule Trigger
  2. Read Queue Rows
  3. Page Attio Meetings
  4. Cursor Complete?
  5. Match Start Instant
  6. Choose Target Record
  7. List Notes On Record
  8. Marker Present?
  9. Create Note
  10. Write Note Id Back
  11. Delete Replaced Note
  12. Count Failures
What it attaches to
The meeting's own company, then the outside attendees' company or person, then the note owner's record.
Two guards, not one
Attio is asked what notes are already on the record, and anything already carrying this note's marker is skipped. The second guard exists because the first one lives in Notion and fails when the create succeeded but the write back did not.
Edited recordings
The API cannot edit a note in place, so an edited recording produces a replacement: create the new note, write the new id back, then delete the old one. A failed create never deletes anything, so the meeting always keeps a note.
Retry ceiling
Five failures and the row is flagged once, then skipped entirely.
A note that fits two meetings
Held, not pushed. Attaching it to the wrong one cannot be undone.
Workflow 3c

Attio to Notion Meeting Pages

Two entry doors: called directly by 3b for every note, or an Attio call recording event. A third door, the note created event, is deliberately switched off, because it does not know which queue row a note came from and created a stray page every time a note was re-edited.

Attio to Notion Meeting Pages40+ nodes
  1. Called By 3b
  2. Which Entry Door
  3. Find By Meeting Id
  4. Verify Key On Page
  5. Find By Queue Relation
  6. Find By Legacy Column
  7. Match Title And Date
  8. Ambiguous?
  9. Create Meeting Page
  10. Rebuild Visibility
  11. Reconcile Duplicates
  1. Key 1Attio meeting or note idno match, fall through
  2. Key 2The queue relationno match, fall through
  3. Key 3Legacy text columnno match, fall through
  4. Key 4Same title, same date, no keysmatch, stop here
  5. Key 5Create the pageonly on a conclusive miss
One run shown. Key 4 adopts a hand written page instead of duplicating it, so key 5 never fires. A create is what happens when all four have genuinely missed, not what happens when a lookup was inconclusive.
Key 1
The Attio meeting id or note id, re-checked on the page it gets back rather than trusted from the search.
Key 2
The queue relation. This is the one that survives a note being republished under a new id.
Key 3
A legacy text column, kept only so older pages are still recognised.
Key 4
A page with the same title and the same date carrying no keys, which is how a hand written page gets adopted instead of duplicated.
Key 5
Create.
Two untagged matches
Two untagged pages sharing a title and a date stops the run and writes nothing, because adopting the wrong one puts a client's transcript on someone else's meeting. An inconclusive lookup stops rather than creating.
Nothing resolved
The property is left out entirely. Writing an empty list would clear a human's manual link.
Visibility
Rebuilt every run from the linked project, falling back to a fixed default set when there is not exactly one project.
Titles and dates
Titles carry no time, so four phone calls on one day look alike, and the date is what separates them. An all day meeting keeps its calendar date. Converting it to local time moved it a day back and broke matching.
Workflow 5

Notion to Attio People Nudge

Workflows 1 and 2 only run when Attio says something changed, so there is no way to ask them to run. A button on a Notion row solves it: change one field on the Attio record, wait fifteen seconds, put the original value back. Two stored changes, so two events fire, and the field ends exactly as it started.

Notion to Attio People Nudge10+ nodes
  1. Notion Button
  2. Read Notion Row
  3. Resolve Attio Record
  4. Match Conclusive?
  5. Change Field
  6. Wait 15s
  7. Restore Field
  8. Log Result
Net data change
Zero. The field ends on the value it started on.
Change then restore, never a toggle
A toggle would strip a legitimate full stop off a real job title.
On demand only
Wiring it to fire on every row edit would loop, because workflow 1 writes that same database constantly.
Anything less than conclusive
No write is issued at all. Not a partial one, none.

Reconcile instead of lock

The brief asked for a lock by meeting id during upserts. n8n cannot hold a distributed lock across executions, and check then create is not atomic, so 3c converges instead of excluding.

Straight after the write it re-queries the key, sorts the live matches by creation time and then by id, keeps the oldest and trashes the rest. Two racing runs sort identically, so they agree on the survivor without shared state. Everything downstream reads the reconciled page id.

sorted by creation time, then by id
Run Akeeps the oldest
Run Bkeeps the oldest
Neither run knows the other exists. They read the same live matches, sort them the same way and land on the same survivor, so running the reconcile twice changes nothing.

When it stops and asks a person

Stopping loudly is deliberate. A silent skip would look identical to a deletion.

  • Two duplicates that both carry hand made links, because both hold real work.
  • More than 100 pages sharing one id.
  • A page matched by name or domain that belongs to a different company record.
  • Two untagged meeting pages sharing a title and a date.
  • A note that matches two meetings.
  • A row that has failed five times.
  • Either API being unreachable.

The obvious build, and this one

The obvious build
Granola writes into Notion directly
Records matched on name
Several workflows write meeting pages
Check, then create, and two runs create two pages
An unsure match gets written anyway
A stuck lock stays stuck
This build
Everything resolves in Attio first, then reaches Notion
Records tied together by id. A name only ever finds a page the first time
One writer per database, one author per meeting page
Write, re-query, sort, keep the oldest, trash the rest
An unsure match stops and waits for a person
A contested steal on a second key. Exactly one run clears it

The Result

Six workflows run in production, more than 250 nodes between them, on self hosted n8n in queue mode with Postgres and Redis behind it.

Attio, Granola and Notion hold one state. A recording resolves to a person and a company, lands on the right Attio record, and reaches the right Notion page without a second page appearing next to it.

Nothing in it improvises. When two answers are equally good, it stops and a person decides.

This is one build of several for Smoothops Consulting, and the arrangement has not changed: they hold the client, Abhiman Labs holds the backend. If that is the shape of the gap in your own delivery, say so here.

6 workflows · 250+ nodes · 13 Redis nodes on the lock · queue mode n8n on Postgres and Redis
← Back to Smoothops Consulting