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