n8n Template · Salesforce · PostgreSQL · Data Sync
Free n8n workflow template that syncs Salesforce leads to PostgreSQL every 15 minutes with zero duplicates. Uses content hashing, ON CONFLICT upserts, and delta syncing via static timestamps.
A scheduled n8n workflow that syncs Salesforce leads to PostgreSQL every 15 minutes. Only processes changed records — not the full table. Uses MD5 content hashing to detect actual field changes, and PostgreSQL’s ON CONFLICT to make every write idempotent. Run it twice, get the same result.
The duplicate problem this solves
Most Salesforce → Postgres sync setups fail the same way: they either re-insert everything on each run (duplicates) or skip records that genuinely changed (stale data). The naive fix — checking if a record exists before inserting — breaks under concurrency and still creates doubles if the workflow fires twice in a short window.
This template solves it with two mechanisms working together. First, delta syncing: n8n stores the last successful sync timestamp in workflow static data and passes it as a SystemModstamp > lastSync filter to Salesforce. Only genuinely new or updated records come through. Second, PostgreSQL’s ON CONFLICT (sf_id) DO UPDATE means the database itself enforces uniqueness — if the same record somehow arrives twice, the second write is a clean update, not a duplicate row.
The MD5 content hash is the third layer. If a record arrives but its key fields haven’t changed, the workflow skips the database write entirely. No unnecessary I/O.
PostgreSQL table setup
Create this table before activating the workflow:
CREATE TABLE salesforce_leads (
id SERIAL PRIMARY KEY,
sf_id VARCHAR(18) UNIQUE NOT NULL,
first_name TEXT,
last_name TEXT,
email TEXT,
company TEXT,
phone TEXT,
status TEXT,
lead_source TEXT,
is_converted BOOLEAN DEFAULT FALSE,
owner_id VARCHAR(18),
sf_created_at TIMESTAMPTZ,
sf_updated_at TIMESTAMPTZ,
content_hash CHAR(32),
synced_at TIMESTAMPTZ
);
-- Index for fast lookups by sf_id (used on every sync check)
CREATE INDEX idx_sf_leads_sf_id ON salesforce_leads(sf_id);
-- Optional: index for querying by status or email downstream
CREATE INDEX idx_sf_leads_status ON salesforce_leads(status);
CREATE INDEX idx_sf_leads_email ON salesforce_leads(email);
The UNIQUE NOT NULL constraint on sf_id is what makes ON CONFLICT work. Without it, the upsert node will throw on the first duplicate.
Workflow nodes
| # | Node | What it does |
|---|---|---|
| 1 | Schedule Trigger | Fires every 15 minutes. Adjust interval in node settings. |
| 2 | Get Last Sync Timestamp (Code) | Reads timestamp from workflow static data. First run defaults to 24 hours ago. |
| 3 | Fetch Updated Leads from Salesforce | SOQL filter: SystemModstamp > lastSync. Fetches all matching fields. |
| 4 | Any Records Found? (IF) | Routes to no-op branch if Salesforce returns empty. |
| 5 | Normalize Fields (Code) | Maps SF fields to snake_case. Lowercases email. Generates MD5 content hash. |
| 6 | Check Existing Record in Postgres | Queries by sf_id — returns existing content_hash if row exists. |
| 7 | Determine INSERT / UPDATE / SKIP (Code) | Compares hashes. Sets _action flag on each record. |
| 8 | Skip Unchanged Records (IF) | Routes SKIP records away from the DB write. |
| 9 | Upsert to PostgreSQL | INSERT ... ON CONFLICT (sf_id) DO UPDATE. Returns was_inserted boolean. |
| 10 | Update Sync Timestamp (Code) | Writes run start time back to static data. Logs insert/update counts. |
| 11 | Log Skipped (Code) | No-op for skipped records. Extend with Slack notification if needed. |
| 12 | No Records — Advance Timestamp (Code) | Still advances the timestamp so next run doesn’t re-scan the same window. |
How to install
- Download the
.jsonfile above. - In n8n: Workflows → Import from File.
- Open node 3 (Fetch Updated Leads from Salesforce) and connect your Salesforce OAuth2 credential.
- Open nodes 6 and 9 (Check Existing Record and Upsert to PostgreSQL) and connect your PostgreSQL credential.
- Run the SQL above to create the
salesforce_leadstable if it doesn’t exist. - Do one manual test run before activating — check the execution log to confirm records flow through correctly.
- Click Activate.
Adjusting the Salesforce object
This template syncs the Lead object. To switch to Contacts or Opportunities, change the resource field in node 3 and update the field list to match. The rest of the workflow — hashing, upsert logic, timestamp management — is object-agnostic.
To sync multiple objects, duplicate the workflow once per object rather than trying to handle branching inside a single workflow. Easier to debug, easier to monitor independently.
Changing the sync interval
15 minutes is a safe default for most teams. If you need near-realtime sync, consider switching the trigger to a Salesforce Outbound Message webhook instead of polling — this eliminates the schedule entirely and pushes changes from Salesforce the moment they happen. The rest of the workflow stays the same.
Known limitations
- Hard deletes in Salesforce are not captured.
SystemModstamponly updates on field changes, not deletions. If you need to sync deletes, you’ll need to query Salesforce’s recycle bin separately or use the Bulk API with a full ID reconciliation pass. - Very large initial syncs (100k+ records on first run) will be slow because node 6 queries Postgres per record. For bulk initial loads, run a direct database migration first, then activate the workflow for delta syncing only.
- Salesforce API limits. Each workflow execution uses API calls against your Salesforce org’s daily limit. At 15-minute intervals that’s 96 calls/day minimum — well within standard limits, but worth monitoring if you add more synced objects.
Extend this workflow
- Add a Slack notification after node 10 summarizing insert/update counts per run
- Write sync stats to a separate
sync_logtable for audit trail - Add a second branch after node 3 for Contacts using the same upsert pattern
- Trigger a downstream dbt model run or Metabase refresh after sync completes
Related
The full writeup behind this template — including why most sync workflows produce duplicates and the exact query pattern that prevents them — is in Syncing Salesforce to PostgreSQL via n8n: The “No-Duplicates” Blueprint.
If your Salesforce API calls start hitting rate limits under load, the retry pattern from Bypassing “429 Too Many Requests”: Implementing Exponential Backoff applies directly to n8n HTTP Request nodes.