Skip to main content

๐ŸŽฏ Salesforce - Authentication & CRM Data

๐Ÿ“– Overviewโ€‹

This document covers the complete Salesforce integration including OAuth 2.0 authentication with JWT state tokens and CRM data export with custom pagination for contacts, opportunities (deals), and notes.

Source Files:

  • Auth Controller: external/Integrations/Salesforce/Controllers/authController.js
  • Export Controller: external/Integrations/Salesforce/Controllers/exportController.js
  • Pagination: external/Integrations/Salesforce/Controllers/pagination.js
  • Provider: external/Integrations/Salesforce/Providers/api.js
  • Model: external/Integrations/Salesforce/Models/keys.js

Salesforce API: https://login.salesforce.com/services/oauth2/, https://{instance}.salesforce.com/services/data/v48.0/


๐Ÿ” Part 1: Authenticationโ€‹

OAuth 2.0 Flow Diagramโ€‹

sequenceDiagram
participant User
participant DC as DashClicks
participant JWT as JWT Handler
participant DB as MongoDB
participant SF as Salesforce

User->>DC: GET /auth/login?forward_url=...
DC->>DB: Check existing token
alt Token valid
DC-->>User: Redirect to forward_url
else No token
DC->>JWT: Create state (1h exp)
DC-->>User: Redirect to Salesforce
User->>SF: Authorize app
SF-->>DC: Callback with code
DC->>JWT: Verify state
DC->>SF: Exchange code for tokens
SF-->>DC: Access + refresh tokens
DC->>DB: Store tokens
DC-->>User: Redirect to forward_url
end

Collections Usedโ€‹

integrations.salesforce.key:

{
_id: ObjectId,
account_id: String,
owner: String,
token: {
access_token: String,
refresh_token: String, // Never expires unless revoked
issued_at: String // Unix timestamp
},
token_invalidated: Boolean
}

Authentication Functionsโ€‹

getLogin(req, res)โ€‹

Endpoint: GET /v1/integrations/salesforce/auth/login

Query Parameters:

  • forward_url (String, required) - Return URL after authentication

Logic:

  1. Validate forward_url parameter exists
  2. Check JWT authentication in req.auth
  3. Query database for existing token
  4. If token exists and not invalidated โ†’ redirect to success
  5. If token_invalidated: true โ†’ delete token
  6. Generate JWT state token (1h expiration):
    {
    aid: account_id,
    uid: user_id,
    forward_url: forward_url
    }
  7. Build Salesforce authorization URL
  8. Redirect user to Salesforce OAuth

Success Response (token exists):

HTTP/1.1 302 Found
Location: {forward_url}?status=success&integration=salesforce

Success Response (new auth):

HTTP/1.1 302 Found
Location: https://login.salesforce.com/services/oauth2/authorize?client_id=...&state={jwt}

Error Response:

{
"succes": false,
"errno": 400,
"message": "Please provide forward_url"
}

callback(req, res)โ€‹

Endpoint: GET /v1/integrations/salesforce/auth/callback

Query Parameters:

  • code (String) - Authorization code
  • state (String) - JWT state token

Logic:

  1. Validate authorization code exists

  2. Verify and decode JWT state token

  3. Exchange code for tokens via Salesforce API:

    POST https://login.salesforce.com/services/oauth2/token
    Content-Type: application/x-www-form-urlencoded

    grant_type=authorization_code&
    client_id={SALESFORCE_CLIENT_ID}&
    client_secret={SALESFORCE_SECRET_ID}&
    redirect_uri={SALESFORCE_REDIRECT_URL}&
    code={code}
  4. Salesforce response:

    {
    "access_token": "00D...",
    "refresh_token": "5Aep...",
    "issued_at": "1704844800000",
    "instance_url": "https://na1.salesforce.com",
    "id": "https://login.salesforce.com/id/00D.../005...",
    "token_type": "Bearer",
    "signature": "..."
    }
  5. Store tokens in database:

    {
    token: {
    access_token: response.data.access_token,
    refresh_token: response.data.refresh_token,
    issued_at: response.data.issued_at
    },
    account_id: aid,
    owner: uid
    }
  6. Redirect to forward_url with success

Success Response:

HTTP/1.1 302 Found
Location: {forward_url}?status=success&integration=salesforce

Error Responses:

# Missing code
Location: ?status=error&integration=salesforce&reason=Authentication not initiated

# Invalid state
{
"success": false,
"message": "Invalid State"
}

# Salesforce error
Location: {forward_url}?status=error&integration=salesforce&reason={error_description}

deleteAccessToken(req, res)โ€‹

Endpoint: DELETE /v1/integrations/salesforce/auth/

Logic:

  1. Verify JWT authentication
  2. Find token by account_id and owner
  3. Delete token from database
  4. Return success confirmation

Success Response:

{
"success": true,
"message": "SUCCESS"
}

Error Responses:

// Token not found
{
"success": false,
"errno": 404,
"message": "Access Token Not Found"
}

// Unauthorized
{
"success": false,
"errno": 400,
"message": "Unauthorized User"
}

Provider Functionsโ€‹

authorizeURL(stateURL)โ€‹

Purpose: Build Salesforce OAuth authorization URL

Returns: Authorization URL string

URL Structure:

https://login.salesforce.com/services/oauth2/authorize?
client_id={SALESFORCE_CLIENT_ID}&
redirect_uri={SALESFORCE_REDIRECT_URL}&
response_type=code&
scope={SALESFORCE_AUTH_SCOPES}&
state={stateURL}

Scopes: api id web refresh_token (URL-encoded as api%20id%20web%20refresh_token)


accessToken(queryCode)โ€‹

Purpose: Exchange authorization code for access tokens

Salesforce API: POST https://login.salesforce.com/services/oauth2/token

Request:

POST /services/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
client_id={SALESFORCE_CLIENT_ID}&
client_secret={SALESFORCE_SECRET_ID}&
redirect_uri={SALESFORCE_REDIRECT_URL}&
code={queryCode}

Response: Axios response object with token data


getRefreshAccessToken(queryData)โ€‹

Purpose: Obtain new access token using refresh token

Parameters:

  • queryData.id - Document ID
  • queryData.token.refresh_token - Stored refresh token

Salesforce API: POST https://login.salesforce.com/services/oauth2/token

Request:

POST /services/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&
client_id={SALESFORCE_CLIENT_ID}&
client_secret={SALESFORCE_SECRET_ID}&
redirect_uri={SALESFORCE_REDIRECT_URL}&
refresh_token={refresh_token}

Response:

{
"access_token": "NEW_ACCESS_TOKEN",
"issued_at": "1704844900000",
"instance_url": "https://na1.salesforce.com",
"id": "...",
"token_type": "Bearer",
"signature": "..."
}

Logic:

  1. Extract refresh_token from stored data
  2. POST to Salesforce token endpoint
  3. Update database with new access_token and issued_at
  4. Keep original refresh_token (not rotated by Salesforce)
  5. Return new token data
Refresh Token Persistence

Unlike other OAuth providers, Salesforce refresh tokens don't rotate. The same refresh_token is reused and must be preserved in the database.


Database Model Functionsโ€‹

findQuery(account_id, owner_id)โ€‹

Purpose: Retrieve stored token

Query:

SalesforceKey.findOne({
account_id: account_id.toString(),
owner: owner_id.toString(),
});

Returns:

{
id: ObjectId, // _id converted
account_id: String,
owner: String,
token: {...}
}

saveRefreshToken(id, data)โ€‹

Purpose: Create or update token document

Operation: Upsert with updateOne()

SalesforceKey.updateOne({ _id: new mongoose.Types.ObjectId(id) }, data, {
upsert: true,
setDefaultsOnInsert: true,
});

deleteToken(id)โ€‹

Purpose: Delete token document

SalesforceKey.deleteOne({ _id: new mongoose.Types.ObjectId(id) });

๐Ÿ“Š Part 2: CRM Data Exportโ€‹

Data Export Flowโ€‹

sequenceDiagram
participant Client
participant Export as Export Controller
participant Paginator
participant DB as MongoDB
participant SF as Salesforce

Client->>Export: GET /export/contacts?page=1&limit=100
Export->>DB: Fetch token
Export->>SF: COUNT query
SF-->>Export: Total records (e.g., 1500)
Export->>Paginator: Calculate pagination
Paginator-->>Export: Pagination object
Export->>SF: Data query with LIMIT + OFFSET
alt Token valid
SF-->>Export: Records
else Token expired (401)
Export->>SF: Refresh token
SF-->>Export: New access token
Export->>DB: Update token
Export->>SF: Retry query
SF-->>Export: Records
end
Export-->>Client: Data + pagination

Export Functionโ€‹

exportData(req, res, next)โ€‹

Endpoint: GET /v1/integrations/salesforce/export/:type

Path Parameters:

  • type (String) - contacts, deals, or notes

Query Parameters:

  • page (Number, default: 1) - Page number
  • limit (Number, optional) - Records per page (default: all records)

Logic:

  1. Verify Authentication

    if (!req.auth) {
    return res.status(401).json({ message: 'Unauthorized User' });
    }
  2. Fetch Stored Token

    let query = await DatabaseQuery.findQuery(req.auth.account_id, req.auth.uid);
    let accessToken = query.token.access_token;
  3. Determine API Endpoint and Count Type

    const type = req.params.type;
    let apiUrl, count_type;

    if (type === 'contacts') {
    apiUrl = contactsURL;
    count_type = 'Contact';
    } else if (type === 'deals') {
    apiUrl = dealsURL;
    count_type = 'opportunity';
    } else if (type === 'notes') {
    apiUrl = notesURL;
    count_type = 'Note';
    }
  4. Get Total Record Count

    let paginationData = await axios.get(`${countAllRecordEndpoint}+${count_type}`, {
    headers: {
    Authorization: `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
    },
    });
    // Response: { "totalSize": 1500, "done": true, "records": [...] }
  5. Calculate Pagination

    const getPaginationValue = pagination.paginator(
    paginationData.data.totalSize,
    pageNo,
    limitValue,
    );
  6. Fetch Data with LIMIT and OFFSET

    if (parseInt(getPaginationValue.page) <= getPaginationValue.total_pages) {
    response = await axios.get(
    `${apiUrl} limit ${getPaginationValue.per_page} offset ${getPaginationValue.offsetValue}`,
    {
    headers: {
    Authorization: `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
    },
    },
    );
    }
  7. Handle Token Expiration (401 INVALID_SESSION_ID)

    if (
    error.response &&
    error.response.status === 401 &&
    error.response.data[0].errorCode === 'INVALID_SESSION_ID'
    ) {
    // Refresh access token
    accessToken = await providers.getRefreshAccessToken(query);

    // Retry COUNT query
    paginationData = await axios.get(/* ... */);

    // Retry data query
    response = await axios.get(/* ... */);
    }
  8. Return Response

    return res.status(200).json({
    success: true,
    message: 'SUCCESS',
    data: (response && response.data && response.data.records) || [],
    pagination: getPaginationValue,
    });

Success Response:

{
"success": true,
"message": "SUCCESS",
"data": [
{
"attributes": {
"type": "Contact",
"url": "/services/data/v48.0/sobjects/Contact/003..."
},
"Name": "John Doe",
"Account": { "Name": "Acme Corp" },
"Title": "Director of Marketing",
"Phone": "+1234567890",
"Email": "john.doe@acme.com",
"Owner": { "Alias": "jsmith" }
}
],
"pagination": {
"page": 1,
"per_page": 100,
"total": 1500,
"total_pages": 15,
"next_page": 2,
"prev_page": null,
"offsetValue": 0
}
}

Error Responses:

// Invalid type
{
"success": false,
"errno": 400,
"message": "Please provide valid type. Example contacts and deals"
}

// Token not found
{
"success": false,
"errno": 400,
"message": "Api Token Not Found"
}

// Unauthorized
{
"success": false,
"errno": 400,
"message": "Unauthorized User"
}

Pagination Systemโ€‹

paginator(items, page, per_page)โ€‹

Purpose: Calculate pagination values for SOQL queries

Source: Controllers/pagination.js

Parameters:

  • items (Number) - Total record count
  • page (Number) - Requested page number
  • per_page (Number) - Records per page (optional, defaults to all items)

Returns:

{
page: 1, // Current page
per_page: 100, // Records per page
total: 1500, // Total records
total_pages: 15, // Total pages
next_page: 2, // Next page (null if last)
prev_page: null, // Previous page (null if first)
offsetValue: 0 // SOQL OFFSET value
}

Calculation Logic:

var page = (page && parseInt(page)) || 1;
var per_page = per_page || items; // Default to all items
var offset = (page - 1) * per_page;
var total_pages = Math.ceil(items / per_page);

return {
page: page,
per_page: per_page,
total: items,
total_pages: total_pages,
next_page: page < total_pages ? page + 1 : null,
prev_page: page > 1 ? page - 1 : null,
offsetValue: offset < items ? offset : 0,
};

SOQL Queries with Paginationโ€‹

Contacts Queryโ€‹

Full Query:

SELECT name, account.name, title, phone, email, contact.owner.alias
FROM contact
LIMIT 100 OFFSET 0

Response:

{
"totalSize": 1500,
"done": true,
"records": [
{
"attributes": {
"type": "Contact",
"url": "/services/data/v48.0/sobjects/Contact/003..."
},
"Name": "John Doe",
"Account": { "Name": "Acme Corp" },
"Title": "Director of Marketing",
"Phone": "+1234567890",
"Email": "john.doe@acme.com",
"Owner": { "Alias": "jsmith" }
}
]
}

Opportunities (Deals) Queryโ€‹

Full Query:

SELECT name, account.name, amount, CloseDate, StageName, opportunity.owner.alias
FROM opportunity
LIMIT 100 OFFSET 0

Response:

{
"totalSize": 850,
"done": true,
"records": [
{
"attributes": {
"type": "Opportunity",
"url": "/services/data/v48.0/sobjects/Opportunity/006..."
},
"Name": "Q1 2024 Deal",
"Account": { "Name": "Acme Corp" },
"Amount": 50000,
"CloseDate": "2024-03-31",
"StageName": "Negotiation/Review",
"Owner": { "Alias": "jsmith" }
}
]
}

Notes Queryโ€‹

Full Query:

SELECT body, IsDeleted, IsPrivate, OwnerId, ParentId, Title, note.owner.alias
FROM note
LIMIT 100 OFFSET 0

Response:

{
"totalSize": 320,
"done": true,
"records": [
{
"attributes": {
"type": "Note",
"url": "/services/data/v48.0/sobjects/Note/002..."
},
"Body": "Follow-up call scheduled for next week",
"IsDeleted": false,
"IsPrivate": false,
"OwnerId": "005...",
"ParentId": "003...",
"Title": "Follow-up Call",
"Owner": { "Alias": "jsmith" }
}
]
}

๐Ÿงช Edge Cases & Special Handlingโ€‹

Token Expiration During Requestโ€‹

Issue: Access token may expire between checks

Handling: Catch 401 INVALID_SESSION_ID error and refresh token:

if (
error.response &&
error.response.status === 401 &&
error.response.data[0].errorCode === 'INVALID_SESSION_ID'
) {
accessToken = await providers.getRefreshAccessToken(query);
// Retry both COUNT and data queries
}

Invalid Type Parameterโ€‹

Issue: User requests unsupported data type

Handling: Return error with supported types:

if (apiUrl === null) {
return res.status(400).json({
message: 'Please provide valid type. Example contacts and deals',
});
}

Pagination Out of Boundsโ€‹

Issue: Requested page exceeds total_pages

Handling: Only execute query if page is valid:

if (parseInt(getPaginationValue.page) <= getPaginationValue.total_pages) {
response = await axios.get(/* ... */);
}

Returns empty array if page exceeds total.

No Limit Specifiedโ€‹

Issue: User doesn't provide limit parameter

Handling: Default to all records:

var per_page = per_page || items; // Returns all items if no limit

โš ๏ธ Important Notesโ€‹

  • ๐Ÿ” JWT State Tokens: 1-hour expiration prevents CSRF attacks
  • ๐Ÿ”„ Refresh Token Persistence: Salesforce refresh tokens don't rotate - keep original
  • ๐Ÿ“Š Two-Query Pattern: COUNT query for pagination + data query with LIMIT/OFFSET
  • โฑ๏ธ Automatic Token Refresh: 401 INVALID_SESSION_ID triggers refresh and retry
  • ๐ŸŒ Instance URLs: Update endpoints based on Salesforce org instance (na1, ap5, etc.)
  • ๐Ÿ“ No Default Limit: If limit not specified, returns ALL records (use cautiously)
  • ๐Ÿ”— Relationship Queries: Dot notation for related objects (account.name, contact.owner.alias)
  • ๐Ÿšจ Error Propagation: Errors passed to Express error middleware via next(error)

๐Ÿš€ Quick Start Examplesโ€‹

Authentication Flowโ€‹

# 1. Initiate OAuth
GET /v1/integrations/salesforce/auth/login?forward_url=https://app.dashclicks.com/success

# 2. User authorizes in Salesforce

# 3. Callback handles token exchange automatically

Export All Contacts (Paginated)โ€‹

let page = 1;
let allContacts = [];

while (true) {
const response = await fetch(
`/v1/integrations/salesforce/export/contacts?page=${page}&limit=200`,
{
headers: { Authorization: `Bearer ${jwt}` },
},
);

const result = await response.json();
allContacts.push(...result.data);

if (!result.pagination.next_page) break;
page = result.pagination.next_page;
}

Export Opportunities with Custom Fieldsโ€‹

Modify SALESFORCE_DEALS_ENDPOINT to include custom fields:

SALESFORCE_DEALS_ENDPOINT=https://na1.salesforce.com/services/data/v48.0/query?q=select+name,+amount,+Custom_Field__c+from+opportunity

Then export:

GET /v1/integrations/salesforce/export/deals?page=1&limit=100
๐Ÿ’ฌ

Documentation Assistant

Ask me anything about the docs

Hi! I'm your documentation assistant. Ask me anything about the docs!

I can help you with:
- Code examples
- Configuration details
- Troubleshooting
- Best practices

Try asking: How do I configure the API?
09:31 AM