Salesforce Connector
The Salesforce connector links a tenant's Salesforce org to Olyron CRM so that leads, opportunities, tasks, notes, and vendor enrollment summaries stay aligned across both systems. It is fully implemented and live: OAuth 2.0 connect/refresh/disconnect run against Salesforce, and every data operation calls the Salesforce REST API and SOQL. This page documents the connector as it ships today.
#Current status
getSupportedProviders() (src/lib/crm-connectors/factory.ts) reports it as implemented: true, so the Integrations screen renders it as “Available” with a working “Connect Salesforce” button. SalesforceConnector (src/lib/crm-connectors/providers/salesforce.ts) performs real OAuth and real REST/SOQL calls against your org.The provider is a first-class member of the CRMProvider union ("zoho" | "salesforce" | "hubspot"). It has an OAuth authorize-URL builder, a shared OAuth callback route that stores the connection, the SalesforceConnector implementation, and a field-mapping UI. Zoho, HubSpot, and Salesforce are all live today.
| Provider | Registered | OAuth URL | Data sync |
|---|---|---|---|
| Zoho CRM | Yes | Yes | Implemented |
| HubSpot | Yes | Yes | Implemented |
| Salesforce | Yes | Yes | Implemented |
#Connector capabilities
SalesforceConnector advertises a capabilities object that the platform reads to decide which sync actions to offer. For Salesforce every flag is true, and each corresponds to a working method.
pushLeadsboolean- Create Salesforce Lead records from Olyron leads via POST /sobjects/Lead.
pullLeadsboolean- Fetch Salesforce Leads into Olyron via a SOQL query.
pushNotesboolean- Attach a note to a Salesforce record using the legacy Note object.
pushTasksboolean- Create Salesforce Task activities via POST /sobjects/Task.
pushDealsboolean- Create Salesforce Opportunity records from Olyron deals.
pullDealsboolean- Fetch Salesforce Opportunities into Olyron via SOQL.
webhooksboolean- Connector is declared webhook-capable for inbound change events.
bidirectionalboolean- Supports two-way sync (push and pull) rather than one-way push.
vendorSyncboolean- Push normalized vendor enrollment data into Salesforce as a completed Task.
#How the connect flow works
The connect experience follows the OAuth 2.0 pattern shared by every CRM connector in Olyron CRM. It starts on the provider connect page and finishes at a shared callback route that persists the connection.
- 1Open the connect pageFrom CRM Integrations (/dashboard/settings/integrations) choose Salesforce, which routes to /dashboard/settings/integrations/salesforce/connect. This page also lets you toggle Vendor enrollment sync and pick which vendor fields to include.
- 2AuthorizeClicking Authorize builds a CSRF
statetoken of the form${tenant.id}:${Date.now()}, stores it (and your vendor-sync preferences) in sessionStorage, and redirects the browser to the Salesforce authorize URL. - 3Grant access in SalesforceSalesforce prompts you to sign in and approve the requested scopes (
api refresh_token offline_access), then redirects back to Olyron with an authorizationcodeand thestate. - 4Token exchange & storageThe callback at /api/crm-connectors/salesforce/callback calls the connector's
connect()method, which POSTs the code to https://login.salesforce.com/services/oauth2/token. The token response includes aninstance_urlthat is stored on the connection and used as the API base. Acrm_connectionsrow is inserted with statusactive. - 5Map fieldsOn success you are redirected to /dashboard/settings/integrations/salesforce/map-fields?connection_id=<id>&success=true to align Olyron fields with Salesforce fields.
https://login.salesforce.com/services/oauth2/authorize
?response_type=code
&client_id=<SALESFORCE_CLIENT_ID>
&redirect_uri=<origin>/api/crm-connectors/salesforce/callback
&scope=api refresh_token offline_access
&state=<tenantId>:<timestamp>Token exchange, refresh, and disconnect
connect()performs theauthorization_codegrant against /services/oauth2/token and returnsaccess_token,refresh_token, andinstance_url. When Salesforce returnsissued_at,expires_atis recorded as roughly two hours later; tokens are otherwise treated as short-lived and refreshed on demand.refreshToken()performs therefresh_tokengrant. Salesforce does not return a new refresh token here, so only the access token (and instance_url) are updated.disconnect()makes a best-effort POST to /services/oauth2/revoke; failures are ignored because the token may already be invalid.
state value is present and splits off the tenant id (state.split(":")[0]); it does not yet verify the token against the value saved in sessionStorage. Treat CSRF validation as still to be hardened.#Callback endpoint reference
A single dynamic route handles the OAuth return for all providers. There is no dedicated Salesforce route — the [provider] segment resolves to salesforce.
| Property | Value |
|---|---|
| Method | GET |
| Path | /api/crm-connectors/salesforce/callback |
| Query params | code (required), state (required) |
| On missing code | redirect → /dashboard/settings/integrations?error=no_code |
| On missing state | redirect → /dashboard/settings/integrations?error=invalid_state |
| On success | redirect → /dashboard/settings/integrations/salesforce/map-fields?connection_id=<id>&success=true |
| On DB failure | redirect → /dashboard/settings/integrations?error=db_error |
The handler calls supabase.auth.getUser() and, if there is no session, redirects to /sign-in?redirect=/dashboard/settings/integrations. The instance_url returned from the token exchange is stored on the connection, and both tokens are wrapped with encryptToken() before being written to crm_connections.
#Token security at rest
Access and refresh tokens are stored in crm_connections.access_token_enc and refresh_token_enc. encryptToken() / decryptToken() in src/lib/crm-connectors/sdk.ts use AES-256-GCM (authenticated encryption) with a 32-byte key derived from CRM_ENCRYPTION_KEY.
Algorithmaes-256-gcm- Authenticated encryption providing confidentiality plus tamper detection via the GCM auth tag.
Formatstringv1:gcm:<iv_b64>:<tag_b64>:<ciphertext_b64>— a random 12-byte IV per token.KeyCRM_ENCRYPTION_KEY- SHA-256 of the env value yields the 32-byte key. Required in production; a warned insecure fallback is used only in non-production.
Legacy tokensbase64- Tokens written before this change are plain base64. They remain readable and are transparently re-encrypted to AES-GCM on the next write.
BaseConnector. For KMS-backed envelope encryption of other secrets, a separate module exists at src/lib/secrets/envelope.ts.NODE_ENV === "production" and CRM_ENCRYPTION_KEY is unset, token storage throws rather than persisting tokens. In non-production the connector logs a warning and falls back to an insecure development key — never rely on that path outside local development.#Data operations (REST & SOQL)
All data calls target the org's REST API at ${instance_url}/services/data/v60.0, authenticated with Authorization: Bearer <access_token>. The base URL comes from the instance_url stored on the connection; if it is missing, the connector throws. HTTP requests run through a shared helper that retries with exponential backoff and tolerates empty/204 responses.
Leads → Salesforce Lead
- Create:
pushLead()POSTs to /sobjects/Lead.Companyis a required Salesforce field, so the connector defaults it tocustom_fields.companywhen present, otherwise"(Individual)". Field mappings and custom fields are merged in, and null/undefined values are stripped before send. - Update:
updateLead()PATCHes /sobjects/Lead/{id}, which returns 204 No Content. Only the fields present in the patch are sent. - Fetch:
fetchLeads()runs a SOQL query via GET /query?q=SELECT ... FROM Lead selecting Id, FirstName, LastName, Email, Phone, Status, LeadSource.searchmatches on Email or LastName;statusfilters on Status; results useLIMIT(default 200) and optionalOFFSET.
Notes → legacy Note object
pushNote() POSTs a single record to /sobjects/Note with { Title, Body, ParentId }. ParentId links the note directly to the parent record (the lead or contact), and Title defaults to "Note" when none is supplied.
Tasks → Salesforce Task
createTask() POSTs to /sobjects/Task. The relationship field depends on the related object type: person records (lead or contact) are linked via WhoId, while other objects (e.g. an Opportunity) use WhatId. Status defaults to "Not Started" and due_date becomes the date-only ActivityDate. Priority is mapped:
| Olyron priority | Salesforce Priority |
|---|---|
| low | Low |
| medium | Normal |
| high | High |
Deals → Salesforce Opportunity
- Create:
pushDeal()POSTs to /sobjects/Opportunity.Name,StageName, andCloseDateare required by Salesforce, soStageNamedefaults to "Prospecting" andCloseDatedefaults to 30 days from now (date-only).AmountandProbabilityare optional. - Update:
updateDeal()PATCHes /sobjects/Opportunity/{id}, returning 204 No Content. - Fetch:
fetchDeals()runs SOQL against Opportunity selecting Id, Name, Amount, StageName, CloseDate, Probability, with an optionalStageNameequality filter and aName LIKE '%...%'search, plusLIMIT(default 200) and optionalOFFSET.
#Connection record & field mapping
Every linked org is one row in the crm_connections table (typed as CRMConnection in src/types/index.ts).
idstring- Connection primary key.
tenant_idstring- Owning tenant; derived from the OAuth
state. provider"salesforce"- CRM provider discriminator.
auth_type"oauth2"- Salesforce connections are always OAuth 2.0.
access_token_encstring- AES-256-GCM encrypted access token.
refresh_token_encstring | null- AES-256-GCM encrypted refresh token (Salesforce refresh tokens do not expire unless revoked).
expires_atstring | null- Access token expiry timestamp (approximate; tokens are refreshed on demand).
instance_urlstring | null- Org-specific API host used as the REST base (e.g. https://na1.salesforce.com).
status"active" | "inactive" | "error"- Connection health.
last_sync_atstring | null- Last successful sync; shown as “Never” until a sync runs.
Salesforce object mapping
| Olyron field | Salesforce object.field |
|---|---|
| first_name | Lead.FirstName |
| last_name | Lead.LastName |
| Lead.Email | |
| phone | Lead.Phone |
| status | Lead.Status |
| source | Lead.LeadSource |
| custom_fields.company | Lead.Company (required; defaults to "(Individual)") |
| title (deal) | Opportunity.Name |
| amount | Opportunity.Amount |
| stage | Opportunity.StageName (defaults to "Prospecting") |
| expected_close_date | Opportunity.CloseDate (defaults to +30 days) |
| probability | Opportunity.Probability |
#Vendor enrollment sync
Beyond standard CRM objects, syncVendorEnrollment(payload) records a normalized insurance/benefits enrollment change in Salesforce. It creates a completed Task (Status "Completed") whose Subject summarizes the vendor and whose Description is a formatted summary of the enrollment (member, plan, status, change type, effective/termination dates, and any change description).
- The Task is linked to the member via
WhoIdwhenpayload.member_external_idis present. ActivityDateis set fromeffective_date(falling back to today), date-only.- On the connect page you can enable vendor sync and choose which attributes to include: Plan Name, Status, Effective Date, Termination Date, and Premium (Plan Name, Status, and Effective Date are enabled by default).
interface VendorEnrollmentSyncPayload {
vendor_id: string;
vendor_name: string;
vendor_code: string;
member_external_id?: string;
member_name?: string;
member_email?: string;
enrollment_plan?: string;
enrollment_status?: string;
effective_date?: string;
termination_date?: string;
change_type?: string;
change_description?: string;
raw_payload?: Record<string, any>;
}#Setup & troubleshooting
Environment variables
SALESFORCE_CLIENT_IDenv- Consumer Key from your Salesforce Connected App. Read by getAuthUrl to build the authorize URL and by connect()/refreshToken() during the token exchange.
SALESFORCE_CLIENT_SECRETenv- Consumer Secret used during the authorization_code and refresh_token grants.
SALESFORCE_REDIRECT_URIenv- Registered callback; must match /api/crm-connectors/salesforce/callback.
CRM_ENCRYPTION_KEYenv- Secret used to derive the AES-256-GCM key for token encryption at rest. Required in production.
| Symptom | Cause & fix |
|---|---|
| "Salesforce OAuth is not configured" | One of SALESFORCE_CLIENT_ID / SALESFORCE_CLIENT_SECRET / SALESFORCE_REDIRECT_URI is missing. Set all three and restart. |
| "Salesforce connection is missing instance_url" | The connection row has no instance_url. Reconnect so the token exchange re-stores it; every REST call needs it as the API base. |
| Redirected with ?error=invalid_state | The callback received no state. Restart the flow from the connect page so a fresh state token is generated. |
| Redirected with ?error=db_error | The crm_connections insert failed — check tenant id and Supabase RLS/policies. |
| Sent to /sign-in on callback | No active Supabase session. Sign in and retry; the redirect param returns you to Integrations. |
| Startup error about CRM_ENCRYPTION_KEY | In production the key is required to store tokens. Set CRM_ENCRYPTION_KEY before connecting. |