Skip to main content

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

ParameterTypeRequiredDescription
forward_urlString✅ YesURL 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

ParameterTypeRequiredDescription
codeString✅ YesAuthorization code from Keap
stateString✅ YesJWT 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

  1. User revokes access in Keap account settings
  2. User changes password (invalidates all tokens)
  3. App permissions changed by Keap admin
  4. 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
💬

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:30 AM