Authentication
📋 Overview
Keap integration uses OAuth 2.0 for authentication with a unique proactive token refresh strategy. Instead of checking token expiration, the integration refreshes tokens on every API request to ensure maximum reliability.
🔐 OAuth 2.0 Flow
Flow Diagram
1. User initiates connection
↓
2. Check for existing valid token
↓ (if not exists or invalidated)
3. Redirect to Keap OAuth
↓
4. User grants "full" scope
↓
5. Keap redirects to callback with code
↓
6. Exchange code for tokens
↓
7. Store tokens in MongoDB
↓
8. Redirect to forward_url with success
↓
9. On subsequent requests:
→ Middleware auto-refreshes token
→ Attach fresh token to request
→ Make API call
🚀 Initiate OAuth Flow
Endpoint
GET /v1/e/keap/auth/login
Authentication
Authorization: Bearer {jwt_token}
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
forward_url | String | ✅ Yes | URL to redirect after OAuth completion |
Request Example
curl -X GET "https://api.dashclicks.com/v1/e/keap/auth/login?forward_url=https://app.dashclicks.com/integrations" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
Pre-Authentication Check
Before redirecting to Keap, the endpoint checks for existing tokens:
const doc = await KeapCollection.getData(accountId.toString(), owner.toString());
if (doc?.token) {
// Token exists - check if invalidated
if (doc?.token_invalidated && doc.token_invalidated == true) {
// Delete invalidated token
await KeapCollection.deleteData(doc._id);
} else {
// Valid token exists - skip OAuth, redirect immediately
const redirectUrl =
forward_url + '?status=success' + '&integration=keap' + '&token=' + doc._id.toString();
return res.redirect(encodeURI(redirectUrl));
}
}
Why this check?
- Avoids unnecessary OAuth redirects if user already connected
- Automatically cleans up invalidated tokens
- Provides seamless user experience for re-connections
Response
HTTP 301 Redirect to Keap OAuth consent screen:
https://signin.infusionsoft.com/app/oauth/authorize?
response_type=code&
state={jwt_state_token}&
client_id={KEAP_CLIENT_ID}&
redirect_uri={KEAP_REDIRECT_URL}&
scope=full
State Parameter
The state parameter contains encrypted information:
let state = {
account_id: req.auth.account_id.toString(),
owner: req.auth.uid.toString(),
forward_url: req.query.forward_url,
};
const stateToken = jwt.sign(state, process.env.APP_SECRET, {
expiresIn: '1h',
});
Purpose: Prevents CSRF attacks and carries account context through OAuth flow.
🔄 OAuth Callback
Endpoint
GET /v1/e/keap/auth/callback
Authentication
None (called by Keap)
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
code | String | ✅ Yes | Authorization code from Keap |
state | String | ✅ Yes | JWT token from login request |
Request Example
# This request is made by Keap, not by your application
GET https://api.dashclicks.com/v1/e/keap/auth/callback?code=abc123...&state=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Processing Flow
1. Validate State Parameter
// Verify and decode JWT state token
const payload = jwt.verify(req.query.state, process.env.APP_SECRET);
const forward_url = payload.forward_url;
const owner = payload.owner;
const accountId = payload.account_id;
Error Response (invalid state):
{redirect_url}?status=error&integration=keap&reason=Authentication not initiated
2. Exchange Code for Tokens
// POST https://api.infusionsoft.com/token
const postBody = {
client_id: process.env.KEAP_CLIENT_ID,
client_secret: process.env.KEAP_CLIENT_SECRET,
code: req.query.code,
grant_type: 'authorization_code',
redirect_uri: process.env.KEAP_REDIRECT_URL,
};
const authcode = await keapProvider.getAccessToken(postBody);
Keap Response:
{
"access_token": "eHbSz8f2aNgBMmSMCKflyvCDFxpF",
"refresh_token": "inxRTuhyitSHFnrzx9yVKyBbIHIGdFdi",
"expires_in": 86400,
"token_type": "bearer",
"scope": "full|rm844.infusionsoft.com"
}
Token Expiration: expires_in: 86400 = 24 hours
3. Store Tokens in MongoDB
const dataToSave = {
token: {
...authcode, // access_token, refresh_token, expires_in, token_type, scope
generated_at: moment().unix(), // Unix timestamp
},
owner: owner.toString(),
account_id: accountId.toString(),
};
const savedData = await KeapCollection.saveData(dataToSave);
Stored Document:
{
_id: ObjectId("507f1f77bcf86cd799439011"),
account_id: "507f191e810c19729de860ea",
owner: "user_Lwh9EzeD8",
token: {
access_token: "eHbSz8f2aNgBMmSMCKflyvCDFxpF",
refresh_token: "inxRTuhyitSHFnrzx9yVKyBbIHIGdFdi",
expires_in: 86400,
token_type: "bearer",
scope: "full|rm844.infusionsoft.com",
generated_at: 1728547200
}
}
4. Redirect to Forward URL
const redirectUrl =
forward_url + '?status=success' + '&integration=keap' + '&token=' + savedData._id.toString();
res.redirect(encodeURI(redirectUrl));
Success Response
HTTP 301 Redirect to forward URL with query parameters:
https://app.dashclicks.com/integrations?status=success&integration=keap&token=507f1f77bcf86cd799439011
Error Response
HTTP 301 Redirect with error information:
https://app.dashclicks.com/integrations?status=error&integration=keap&reason={error_message}
🔄 Automatic Token Refresh
Middleware: getToken
The getToken middleware runs before every API request to ensure a fresh token:
const getToken = async (req, res, next) => {
const account_id = req.auth.account_id;
const owner = req.auth.uid;
// 1. Retrieve stored token
const doc = await KeapCollection.getData(account_id.toString(), owner.toString());
if (!doc) {
return res.status(401).json({
message: 'User oauth token not found. Please redirect to login',
});
}
const docID = doc._id;
// 2. Refresh token (always, regardless of expiration)
try {
const url = 'https://api.infusionsoft.com/token';
const requestData = qs.stringify({
grant_type: 'refresh_token',
refresh_token: doc.token.refresh_token,
});
// 3. Make refresh request with Basic Auth
const base64Credentials = Buffer.from(
`${process.env.KEAP_CLIENT_ID}:${process.env.KEAP_CLIENT_SECRET}`,
).toString('base64');
const response = await axios.post(url, requestData, {
headers: {
Authorization: `Basic ${base64Credentials}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
});
// 4. Update stored token
const dataToUpdate = {
token: {
generated_at: moment().unix(),
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires_in: response.data.expires_in,
},
};
await KeapCollection.updateData(docID, dataToUpdate);
// 5. Attach fresh token to request
req.access_token = response.data.access_token;
next();
} catch (error) {
next(error);
}
};
Refresh Request Format
POST https://api.infusionsoft.com/token
Authorization: Basic {base64(client_id:client_secret)}
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token={refresh_token}
Refresh Response
{
"access_token": "newAccessToken123",
"refresh_token": "newRefreshToken456",
"expires_in": 86400,
"token_type": "bearer",
"scope": "full|rm844.infusionsoft.com"
}
Note: Keap returns both a new access token and a new refresh token on each refresh.
Why Always Refresh?
The integration refreshes tokens on every request instead of checking expiration:
Advantages:
- ✅ Eliminates timing issues with expiration checks
- ✅ Ensures maximum token freshness
- ✅ Simplifies logic (no expiration math required)
- ✅ Handles clock drift between servers
- ✅ Prevents edge cases where token expires during request
Commented Out Expiration Check:
// Original code (commented out):
// if (moment().unix() < doc.token.generated_at + doc.token.expires_in) {
// req.access_token = doc.token.access_token;
// next();
// } else {
// // refresh token
// }
// Current approach: Always refresh
🚫 Token Invalidation
Detection
The integration detects invalidated tokens from Keap API error responses:
// Error response structure
{
"fault": {
"faultstring": "Invalid Access Token",
"detail": {
"errorcode": "keymanagement.service.invalid_access_token"
}
}
}
// Error handler in index.js
if (error?.response?.data?.fault?.detail?.errorcode ===
'keymanagement.service.invalid_access_token') {
// Mark all tokens for this account as invalidated
await keapKeys.updateMany(
{ account_id: accountId.toString() },
{ $set: { token_invalidated: true } }
);
error.message = 'TOKEN_INVALIDATED';
}
When Tokens Become Invalid
- User revokes access in Keap account settings
- User changes password (invalidates all tokens)
- App permissions changed by Keap admin
- Account suspended or deactivated
Response to Client
{
"success": false,
"errno": 400,
"message": "TOKEN_INVALIDATED"
}
Handling Invalidation
try {
const response = await axios.get(url, { headers });
} catch (error) {
if (error.response?.data?.message === 'TOKEN_INVALIDATED') {
// Redirect to re-authenticate
window.location.href = `/v1/e/keap/auth/login?forward_url=${encodeURIComponent(
window.location.href,
)}`;
}
}
🗑️ Disconnect Integration
Endpoint
DELETE /v1/e/keap/auth
Authentication
Authorization: Bearer {jwt_token}
Required Scopes
['contacts', 'contacts.external'];
Request Example
curl -X DELETE "https://api.dashclicks.com/v1/e/keap/auth" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
Processing Logic
const owner = req.auth.uid;
const accountId = req.auth.account_id;
// 1. Find token document
const doc = await KeapCollection.getData(accountId.toString(), owner.toString());
if (!doc) {
return res.status(400).json({
success: false,
errno: 400,
message: 'NOT_EXIST',
});
}
// 2. Delete token document
await KeapCollection.deleteData(doc._id.toString());
Success Response
{
"success": true,
"message": "SUCCESS"
}
Error Response
{
"success": false,
"errno": 400,
"message": "NOT_EXIST"
}
🔐 OAuth Scope Explained
Full Scope
Keap integration requests the full scope:
scope=full
Permissions Granted:
- ✅ Read/write access to all CRM data (contacts, companies, opportunities)
- ✅ Read/write access to notes and tasks
- ✅ Access to tags and custom fields
- ✅ Access to email and marketing automation
- ✅ Complete account management
Why full scope?
- Simplifies integration (no need to request additional scopes later)
- Matches common use cases (complete CRM data export)
- Reduces OAuth friction (single consent for all features)
Scope in Response:
{
"scope": "full|rm844.infusionsoft.com"
}
The scope includes the Keap subdomain (rm844.infusionsoft.com) to identify which account was authorized.
🎯 Use Cases
1. Initial Connection
// Frontend: Redirect to login endpoint
window.location.href = `/v1/e/keap/auth/login?forward_url=${encodeURIComponent(
window.location.href,
)}`;
// Backend: Handles OAuth and stores tokens
// Frontend: Receives redirect with token ID
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('status') === 'success') {
const tokenId = urlParams.get('token');
localStorage.setItem('keapToken', tokenId);
console.log('Keap connected successfully!');
}
2. Re-authentication After Token Invalidation
// API request returns TOKEN_INVALIDATED
async function exportDataWithRetry(type) {
try {
const response = await axios.get(`/v1/e/keap/export/${type}`, {
headers: { Authorization: `Bearer ${jwt_token}` },
});
return response.data;
} catch (error) {
if (error.response?.data?.message === 'TOKEN_INVALIDATED') {
// Clear stored state
localStorage.removeItem('keapToken');
// Redirect to re-authenticate
window.location.href = `/v1/e/keap/auth/login?forward_url=${encodeURIComponent(
window.location.href,
)}`;
}
throw error;
}
}
3. Check Connection Status
// Check if user has valid Keap connection
async function checkKeapConnection() {
try {
const response = await axios.get('/v1/e/keap/export/contacts?limit=1&page=1', {
headers: { Authorization: `Bearer ${jwt_token}` },
});
return { connected: true, valid: true };
} catch (error) {
if (error.response?.status === 401) {
return { connected: false, valid: false };
}
if (error.response?.data?.message === 'TOKEN_INVALIDATED') {
return { connected: true, valid: false };
}
throw error;
}
}
// Usage
const status = await checkKeapConnection();
if (!status.connected) {
console.log('Not connected - show connect button');
} else if (!status.valid) {
console.log('Token invalid - show reconnect button');
} else {
console.log('Connected and valid - show data');
}
4. Disconnect Integration
// Revoke Keap connection
async function disconnectKeap() {
try {
const response = await axios.delete('/v1/e/keap/auth', {
headers: { Authorization: `Bearer ${jwt_token}` },
});
if (response.data.success) {
localStorage.removeItem('keapToken');
console.log('Keap disconnected successfully');
}
} catch (error) {
console.error('Failed to disconnect Keap:', error);
}
}
⚠️ Error Scenarios
1. Missing Forward URL
Request:
GET /v1/e/keap/auth/login
Response:
{
"message": "Please provide forward_url"
}
Solution: Always include forward_url query parameter
2. Invalid State Token
Cause: State token expired (>1 hour) or tampered with
Redirect:
{forward_url}?status=error&integration=keap&reason=Authentication not initiated
Solution: Restart OAuth flow from /auth/login
3. OAuth Code Missing
Cause: User denied permission or code exchange failed
Redirect:
{forward_url}?status=error&integration=keap&reason=Authentication not initiated
Solution: User must grant permissions on Keap consent screen
4. Token Not Found
Request:
GET /v1/e/keap/export/contacts
Response:
{
"message": "User oauth token not found. Please redirect to login"
}
HTTP Status: 401 Unauthorized
Solution: Redirect to /auth/login to connect Keap account
5. Token Refresh Failed
Cause: Refresh token expired or revoked
Response: Propagates error from Keap API
Solution: Delete invalidated token and re-authenticate
6. Connection Already Exists
Request:
GET /v1/e/keap/auth/login?forward_url=...
Response: Immediate redirect (skips OAuth)
{forward_url}?status=success&integration=keap&token={existing_token_id}
Why: Improves UX by detecting existing valid connections
🔒 Security Best Practices
1. State Parameter Validation
// Always verify state JWT before processing callback
const payload = jwt.verify(req.query.state, process.env.APP_SECRET);
Prevents: CSRF attacks, account hijacking
2. Token Storage Security
- ✅ Tokens stored in MongoDB (not frontend)
- ✅ Only token IDs returned to client
- ✅ Access tokens never exposed to frontend
- ⚠️ Consider encrypting tokens at rest (recommended enhancement)
3. HTTPS Only
# Production
KEAP_REDIRECT_URL=https://api.dashclicks.com/v1/e/keap/auth/callback
# Development (use ngrok or similar)
KEAP_REDIRECT_URL=https://dev.example.com/v1/e/keap/auth/callback
4. Scope Isolation
- Endpoints protected with
verifyScope(['contacts', 'contacts.external']) - Ensures only authorized users access CRM data
5. Token Invalidation Handling
- Automatic detection via error codes
- Bulk invalidation for all user tokens
- Forces re-authentication before data access