Salesforce Sensor
What it ingests
CRM records and the activity stream from a Salesforce org:
| Salesforce object | Ontology entity | Signal |
|---|---|---|
| Account | Organization | account |
| Contact | Person (identity) | contact |
| Opportunity | Project | opportunity |
| Case | Issue | case |
| Note | Comment | note |
| EmailMessage | Comment | email |
Task (TaskSubtype = Call) | Comment | call |
| Event | Meeting | meeting |
| Task (everything else) | ActionItem | task |
The engagements are the point. Plenty of systems know which opportunities are open; what Salesforce adds is the record of what was actually said to the customer — note bodies, email bodies, call notes, meeting descriptions. Each activity is resolved to the opportunity, account, case, or contact it concerns.
Read-only and poll-based. Each object kind has its own signal, so you can disable a noisy stream (emails, typically) from the source's bindings without disabling the source.
Credential
References a salesforce_connected_app credential by id. The secret is a JSON blob:
{
"login_url": "https://mycompany.my.salesforce.com",
"client_id": "<Connected App Consumer Key>",
"client_secret": "<Connected App Consumer Secret>"
}login_url is your My Domain (production, sandbox, or scratch org). login.salesforce.com and test.salesforce.com also work. After the token mint, Salesforce returns the instance_url used for subsequent REST calls, so the instance is not stored.
OAuth web-server, username-password, and JWT-bearer are not supported. Client credentials is the server-to-server grant: no browser, no refresh loop, no callback endpoint.
curl -sS -X POST https://your-corveil-host/api/credentials \
-H "Authorization: Bearer <admin>" -H "Content-Type: application/json" \
-d '{"type":"salesforce_connected_app","name":"acme-salesforce","secret":"{\"login_url\":\"https://mycompany.my.salesforce.com\",\"client_id\":\"3MVG9…\",\"client_secret\":\"…\"}"}'Config fields
| Key | Required | Notes |
|---|---|---|
enabled_objects | no | Comma-separated object kinds. Blank polls every supported kind. Unknown names are rejected at configure time. |
default_entity_type | no | Override the graph entity type produced from each record. Blank uses the per-object fallback (Organization for accounts, Project for opportunities, …). Validated against the org taxonomy on save. |
Supported object names: accounts, contacts, opportunities, cases, notes, emails, calls, meetings, tasks.
Provider-side setup
- In Salesforce Setup, go to App Manager → New Connected App (or External Client App).
- Enable OAuth Settings and Client Credentials Flow.
- Assign a run-as user with API access (a dedicated integration user is best — the sensor inherits that user's object permissions).
- Add the
apiOAuth scope (Access and manage your data). - Copy the Consumer Key (
client_id) and Consumer Secret.
The run-as user needs read access to every object kind you enable. A missing object permission fails that kind's walk and continues with the rest — it does not park the whole sensor.
Wire-up
POST /api/sensors
{
"sensor_type": "salesforce",
"name": "Acme Salesforce",
"config": {
"enabled_objects": "accounts, contacts, opportunities, notes, emails, calls, meetings"
},
"credentials": { "connected_app": "<salesforce_connected_app credential id>" }
}Verify
Run Test connection. It mints a token via client credentials and reads /services/oauth2/userinfo. A failure here is almost always: Client Credentials Flow not enabled, a missing run-as user, or the api scope.
Once polling, each object kind's count appears in the per-poll summary log (accounts_collected, emails_collected, …). A kind stuck at zero while the others move is the signal to check that kind's object permission on the run-as user.
How incremental sync works
- SOQL on
LastModifiedDate, oldest first.GET /services/data/v62.0/querywithORDER BY LastModifiedDate ASC, Id ASC. Query-locator pagination drains a page of 200; a burst beyond the per-poll page cap spills into the next cycle via the watermark. - Each object kind has its own watermark, so enabling a new kind backfills it from its own zero without dragging the others back.
- Each poll re-reads a two-minute overlap.
LastModifiedDateis second-precision on many objects; a strict resume would skip a record committed in the same second as the watermark. Re-read events are deduplicated. - Deletes are invisible. The sensor uses
/query, not/queryAll, so deleted and archived rows never appear. A record vanishing from results is not treated as a deletion. - Calls and tasks are disjoint Task queries. Logged calls are
Task WHERE TaskSubtype = 'Call'; everything else on Task is thetasksignal. An operator can disable one without losing the other. - Rate limits are shared. Salesforce's daily API request budget is shared with every integration in the org. Daily exhaustion (
REQUEST_LIMIT_EXCEEDED) leaves the cursor unadvanced and backs off for the cycle rather than retrying.
Not included
- OAuth web-server / refresh tokens. Only needed for user-interactive apps.
- Username-password. Salesforce has deprecated that grant.
- JWT bearer. Requires a certificate; client credentials is the same server-to-server posture without one.
- Enhanced notes (
ContentNote). v1 walks classicNote. Orgs that have disabled classic notes will seenotes_collectedstay at zero. - Leads, Campaigns, custom objects, Chatter. Deferred.
- Bulk API 2.0. REST query is enough for incremental deltas; bulk is a backfill optimization.
- Webhooks / Platform Events / Change Data Capture. Polling is the supported path.