Bring Salesforce account and pipeline context into Notion. One deploy creates related Salesforce Accounts and Salesforce Opportunities databases, then keeps them current with five-minute updates and daily reconciliation.
You do not need to create the Notion databases or provide a Notion API token. Notion creates and manages both databases from the schemas in this Worker.
You need Node.js 22+, a Salesforce production org or sandbox with API access, and a local External Client App configured for the client-credentials flow. Copy its consumer key and secret, and note the org's My Domain URL.
From the repository root:
npm install --global ntn
cd workers/templates/salesforce-sync
npm install
ntn login
ntn workers deploy --name salesforce-sync
ntn workers env set SALESFORCE_CLIENT_ID=your-consumer-key
ntn workers env set SALESFORCE_CLIENT_SECRET=your-consumer-secret
ntn workers env set SALESFORCE_ORG_URL=https://acme.my.salesforce.comUse --name salesforce-sync only for the first deployment. After workers.json
identifies the deployed Worker, update it with ntn workers deploy.
Preview both databases without changing Notion, then start the real syncs:
ntn workers sync trigger accountsSync --preview
ntn workers sync trigger opportunitiesSync --preview
ntn workers sync trigger accountsSync
ntn workers sync trigger opportunitiesSyncThe first run backfills every Account and Opportunity visible to the integration user. After it completes, incremental updates run every five minutes and full replacement sweeps run daily.
The selected fields and descriptions for every visible Account and Opportunity are copied into Notion. Review both managed databases' sharing settings before giving a broader audience access.
| Managed database | Questions it helps answer |
|---|---|
| Salesforce Accounts | Which accounts are largest by revenue or headcount? How are accounts distributed by owner, industry, type, country, or recent changes? |
| Salesforce Opportunities | What is closing soon? How does pipeline break down by stage, owner, forecast category, lead source, probability, or account? |
The Opportunity database relates each record to its Salesforce Account, so you can move from an account to its open and historical opportunities in Notion.
This example reads the standard Salesforce Account and Opportunity objects from production orgs and sandboxes. Authentication uses the OAuth client-credentials flow from a Salesforce External Client App with a dedicated integration user as its Run As user.
The default mapping is intended for single-currency orgs. It stores Amount
and AnnualRevenue as numbers without CurrencyIsoCode; add that field before
using the Worker with a multi-currency org. Custom objects, custom fields, and
Person Account-specific fields are not included by default.
| Managed database | Salesforce object | Incremental updates | Full reconciliation |
|---|---|---|---|
| Salesforce Accounts | Account | Every 5 min | Daily |
| Salesforce Opportunities | Opportunity | Every 5 min | Daily |
| Notion property | Salesforce field | Type |
|---|---|---|
| Name | Name |
title |
| Industry | Industry |
select |
| Type | Type |
select |
| Website | Website |
url |
| Phone | Phone |
phoneNumber |
| Billing City | BillingCity |
richText |
| Billing Country | BillingCountry |
richText |
| Annual Revenue | AnnualRevenue |
number |
| Employees | NumberOfEmployees |
number |
| Owner | Owner.Name |
richText |
| Created | CreatedDate |
date |
| Updated | LastModifiedDate |
date |
| Account Link | Salesforce record URL | url |
| Account ID | Id |
richText |
Each Account page body contains its Salesforce Description. Account ID is
the stable sync key.
| Notion property | Salesforce field | Type |
|---|---|---|
| Name | Name |
title |
| Stage | StageName |
select |
| Amount | Amount |
number |
| Probability | Probability |
number (percent) |
| Close Date | CloseDate |
date |
| Type | Type |
select |
| Lead Source | LeadSource |
select |
| Forecast Category | ForecastCategoryName |
select |
| Is Closed | IsClosed |
checkbox |
| Is Won | IsWon |
checkbox |
| Owner | Owner.Name |
richText |
| Account | AccountId |
relation |
| Created | CreatedDate |
date |
| Updated | LastModifiedDate |
date |
| Opportunity Link | Salesforce record URL | url |
| Opportunity ID | Id |
richText |
Each Opportunity page body contains its Salesforce Description. Salesforce
percentages are converted to Notion's decimal percent format. Account is a
two-way relation keyed by AccountId; Notion adds the reciprocal
Opportunities property to each related Account.
accountsSyncandopportunitiesSyncquery Salesforce'squeryAllREST resource every five minutes using a fixedSystemModstampwindow.- A two-minute overlap makes boundary retries safe. Salesforce query locators page through up to 2,000 records per response.
- Live records become Notion upserts; records marked
IsDeletedbecome explicit deletes. accountsReconciliationandopportunitiesReconciliationperform daily replacement sweeps. They remove pages for purged records, permission changes, and updates missed while the Worker was unavailable.- The client uses Salesforce REST API v67.0, caches one client-credentials access token, and renews it once after an HTTP 401.
- All four syncs share a conservative request pacer. Salesforce limit responses are passed to the Workers runtime for retry and backoff.
The five-minute schedule is the normal update target, not a hard guarantee. Throttling, an unavailable Worker, or a long initial backfill can increase latency.
- A soft-deleted record returned by
queryAllis normally removed after the next successful incremental run. - A purged record, a newly hidden record, or a deletion missed during an outage is removed after the next successful daily reconciliation.
- A replacement sweep removes records the integration user can no longer see.
Use a dedicated integration user rather than a human administrator. Where available, Salesforce recommends the Salesforce Integration user license with the Minimum Access - API Only Integrations profile.
Grant only the access this read-only Worker needs, preferably through a dedicated permission set:
- API Enabled system permission
- Read object permission on Account and Opportunity
- Read field-level access to every field in the property maps, including
Description,SystemModstamp,IsDeleted, andOwner.Name - View All Records on each object only when the Notion databases must represent every record in the org
- Access to the External Client App when its policy requires pre-authorization
Do not grant write access, Modify All Records, or View All Data. If the sync should follow the integration user's Salesforce sharing rules, omit View All Records and expect daily reconciliation to remove records that user can no longer see.
- In Salesforce Setup, find External Client App Manager and select New External Client App.
- Enter the app information and choose Local as its distribution state.
- Under API (Enable OAuth Settings), enable OAuth.
- Enter
https://login.salesforce.com/services/oauth2/successas the callback URL. The client-credentials flow does not use the redirect, but Salesforce requires a value. - Enable Client Credentials Flow. Leave Authorization Code and Credentials Flow disabled.
- Add only Manage user data via APIs (
api) as an OAuth scope. - Create the app. In Policies, enable client credentials and select the dedicated integration user as Run As.
- Set Permitted Users to Admin approved users are pre-authorized and associate the integration user's permission set.
- In Settings, open Consumer Key and Secret and copy both values.
New External Client Apps can take several minutes to become available. This server-to-server flow has no interactive Salesforce sign-in and no refresh token.
| Variable | Required | Description |
|---|---|---|
SALESFORCE_CLIENT_ID |
Yes | External Client App consumer key |
SALESFORCE_CLIENT_SECRET |
Yes | External Client App consumer secret |
SALESFORCE_ORG_URL |
Yes | Production or sandbox My Domain origin, such as https://acme.my.salesforce.com |
Use the specific org's My Domain URL, including for sandboxes—not
login.salesforce.com or test.salesforce.com. It must be an HTTPS origin with
no credentials, path, query string, or fragment.
src/
├── index.ts — registers the two databases and four syncs
├── salesforce.ts — client credentials, REST requests, and pagination
├── sync.ts — incremental and replacement sync lifecycle
├── accounts.ts — Account fields, schema, and transform
└── opportunities.ts — Opportunity fields, schema, and transform
Each resource module owns its field list, Salesforce type, managed-database schema, and transform. To add a field:
- Give the integration user read field-level access.
- Add its API name to
ACCOUNT_FIELDSorOPPORTUNITY_FIELDS. - Add it to the corresponding Salesforce TypeScript type.
- Add the matching Notion property and transform value.
Salesforce returns only fields included in the SOQL SELECT list. Map nullable
values to an empty property value ([]) so clearing a field upstream also
clears its previous value in Notion.
To add an object, follow the Account and Opportunity modules, extend the
SalesforceResource object-name type in src/sync.ts, then register an
incremental sync and a replacement reconciliation in src/index.ts. Use the
same immutable Salesforce ID as the key for both.
Offline checks require no Salesforce connection:
npm run check
npm testTo run against the configured org, pull the deployed environment and execute a sync locally:
ntn workers env pull
ntn workers exec accountsSync --local
ntn workers exec opportunitiesSync --localUse a sandbox while changing schemas or queries.
- Authentication errors: confirm that the key and secret came from the
External Client App, client credentials is enabled in its policy, the Run As
user is active, and
SALESFORCE_ORG_URLis the exact My Domain origin. INVALID_FIELDor insufficient access: verify object and field-level read access for every field inACCOUNT_FIELDSandOPPORTUNITY_FIELDS.- Missing records: the Run As user's sharing rules define visibility unless View All Records is granted separately on both objects.
- Empty Account relations: sync Accounts first, and confirm each Opportunity
has a readable
AccountIdmatching an Account visible to the integration user. - Delayed runs: Salesforce quotas vary by org. A throttled run completes only after the Workers runtime retries it.