How to securely integrate Salesforce CRM endpoints into WordPress custom plugins using Heartbeat API
Establishing Secure Salesforce API Access in WordPress
Integrating Salesforce CRM data into a WordPress e-commerce platform offers significant advantages for managing customer relationships, sales pipelines, and marketing campaigns. However, direct, unauthenticated API calls from a public-facing WordPress site to Salesforce are a critical security vulnerability. This document outlines a robust, secure method for integrating Salesforce CRM endpoints into WordPress custom plugins, leveraging the WordPress Heartbeat API for near real-time, secure data synchronization and user interaction.
We will focus on a PHP-based implementation within a WordPress plugin, demonstrating how to securely authenticate with Salesforce and trigger data synchronization or retrieval operations without exposing sensitive credentials or overwhelming the Salesforce API with direct, frequent requests from the client-side.
Salesforce Authentication Strategy: OAuth 2.0 JWT Bearer Flow
For server-to-server integrations where a user is not directly involved in the authentication process, the OAuth 2.0 JWT Bearer Flow is the most secure and recommended method for connecting WordPress to Salesforce. This flow eliminates the need for storing client secrets or refresh tokens directly in the WordPress application. Instead, it uses a digital certificate (private key) to sign JSON Web Tokens (JWTs) that are exchanged for access tokens.
Prerequisites:
- A Salesforce Connected App configured for JWT Bearer Flow.
- A self-signed certificate or a certificate signed by a trusted Certificate Authority (CA) uploaded to Salesforce.
- The public certificate uploaded to the Salesforce Connected App.
- The private key corresponding to the public certificate securely stored on your WordPress server.
- The Salesforce Consumer Key (Client ID) for your Connected App.
- The Salesforce Login URL (e.g.,
https://login.salesforce.comfor production,https://test.salesforce.comfor sandboxes).
Key Generation and Storage:
You’ll need to generate a private key and a corresponding public certificate. For production, use a certificate signed by a trusted CA. For development or testing, a self-signed certificate can suffice. The private key must be stored securely on your WordPress server, ideally outside the web-accessible directory and with strict file permissions (e.g., chmod 400).
Example using OpenSSL to generate a self-signed certificate and private key:
openssl req -x509 -sha256 -nodes -days 365 -newkey rsa:2048 -keyout salesforce_private.key -out salesforce_public.crt -subj "/C=US/ST=California/L=San Francisco/O=MyCompany/CN=salesforce.mycompany.com"
Upload salesforce_public.crt to your Salesforce Connected App. Keep salesforce_private.key secure on your server.
Implementing the JWT Bearer Token Exchange
The JWT Bearer Flow involves constructing a JWT, signing it with the private key, and then exchanging it for an access token from Salesforce’s OAuth endpoint. We’ll create a PHP class to encapsulate this logic.
First, ensure you have a robust JWT library available. The firebase/php-jwt library is a popular and reliable choice. You can install it via Composer:
composer require firebase/php-jwt
Now, let’s define the PHP class for handling the Salesforce authentication.
<?php
namespace MyPlugin\Salesforce;
use Firebase\JWT\JWT;
use WP_Error;
class SalesforceAuthenticator {
private $consumer_key;
private $private_key_path;
private $salesforce_login_url;
private $username; // Salesforce username for the integration user
public function __construct(string $consumer_key, string $private_key_path, string $salesforce_login_url, string $username) {
$this->consumer_key = $consumer_key;
$this->private_key_path = $private_key_path;
$this->salesforce_login_url = rtrim($salesforce_login_url, '/');
$this->username = $username;
if (!file_exists($this->private_key_path) || !is_readable($this->private_key_path)) {
throw new \InvalidArgumentException("Private key file not found or not readable at: " . $this->private_key_path);
}
}
/**
* Generates a JWT for Salesforce authentication.
*
* @return string The signed JWT.
* @throws \Exception If JWT generation fails.
*/
private function generate_jwt() : string {
$private_key = file_get_contents($this->private_key_path);
if ($private_key === false) {
throw new \Exception("Failed to read private key file.");
}
$now = time();
$exp = $now + 180; // Token valid for 3 minutes (180 seconds)
$payload = [
'iss' => $this->consumer_key,
'sub' => $this->username,
'aud' => $this->salesforce_login_url . '/services/oauth2/token',
'exp' => $exp,
'iat' => $now,
];
// The algorithm for JWT signing. Salesforce supports RS256.
$algorithm = 'RS256';
try {
$jwt = JWT::encode($payload, $private_key, $algorithm);
return $jwt;
} catch (\Exception $e) {
throw new \Exception("JWT encoding failed: " . $e->getMessage());
}
}
/**
* Exchanges the JWT for an OAuth access token from Salesforce.
*
* @return array|WP_Error An array containing access_token and instance_url, or WP_Error on failure.
*/
public function get_access_token() {
try {
$jwt = $this->generate_jwt();
} catch (\Exception $e) {
error_log("Salesforce JWT generation error: " . $e->getMessage());
return new WP_Error('salesforce_jwt_error', __('Failed to generate JWT for Salesforce authentication.', 'my-plugin-textdomain'));
}
$token_endpoint = $this->salesforce_login_url . '/services/oauth2/token';
$body = [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
];
$response = wp_remote_post($token_endpoint, [
'method' => 'POST',
'timeout' => 30,
'body' => http_build_query($body),
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
],
]);
if (is_wp_error($response)) {
error_log("Salesforce token request WP_Error: " . $response->get_error_message());
return new WP_Error('salesforce_api_error', __('Error connecting to Salesforce API.', 'my-plugin-textdomain'));
}
$status_code = wp_remote_retrieve_response_code($response);
$response_body = wp_remote_retrieve_body($response);
$data = json_decode($response_body, true);
if ($status_code !== 200 || !isset($data['access_token']) || !isset($data['instance_url'])) {
$error_message = isset($data['error_description']) ? $data['error_description'] : 'Unknown error';
error_log("Salesforce token request failed. Status: {$status_code}, Response: {$response_body}");
return new WP_Error('salesforce_auth_failed', sprintf(__('Salesforce authentication failed: %s', 'my-plugin-textdomain'), $error_message));
}
return [
'access_token' => $data['access_token'],
'instance_url' => $data['instance_url'],
'token_type' => $data['token_type'] ?? 'Bearer', // Usually Bearer
'issued_at' => $data['issued_at'] ?? null,
'signature' => $data['signature'] ?? null,
];
}
}
?>
Configuration:
Store your Salesforce credentials securely. For a WordPress plugin, this typically means using WordPress’s options API, but encrypting sensitive values or using environment variables is highly recommended. For this example, we’ll assume you have these values available.
<?php
// In your plugin's main file or an initialization class:
// Load Composer's autoloader
require_once plugin_dir_path( __FILE__ ) . 'vendor/autoload.php';
use MyPlugin\Salesforce\SalesforceAuthenticator;
// --- Securely retrieve these values ---
// For demonstration, using constants. In production, use wp_options,
// environment variables, or a secure configuration file.
define('MY_PLUGIN_SALESFORCE_CONSUMER_KEY', 'YOUR_SALESFORCE_CONSUMER_KEY');
define('MY_PLUGIN_SALESFORCE_PRIVATE_KEY_PATH', WP_CONTENT_DIR . '/secure/salesforce_private.key'); // Ensure this path is correct and secure
define('MY_PLUGIN_SALESFORCE_LOGIN_URL', 'https://login.salesforce.com'); // Or your sandbox URL
define('MY_PLUGIN_SALESFORCE_USERNAME', '[email protected]'); // Salesforce integration user
// --- Instantiate the authenticator ---
try {
$sf_authenticator = new SalesforceAuthenticator(
MY_PLUGIN_SALESFORCE_CONSUMER_KEY,
MY_PLUGIN_SALESFORCE_PRIVATE_KEY_PATH,
MY_PLUGIN_SALESFORCE_LOGIN_URL,
MY_PLUGIN_SALESFORCE_USERNAME
);
// You can now call $sf_authenticator->get_access_token() to get credentials.
// It's best to cache the access token and its expiry to avoid frequent requests.
} catch (\InvalidArgumentException $e) {
error_log("Salesforce Authenticator initialization error: " . $e->getMessage());
// Handle error appropriately, e.g., disable Salesforce features.
}
?>
Leveraging the WordPress Heartbeat API for Secure Data Sync
The WordPress Heartbeat API provides a mechanism for the browser to send periodic AJAX requests to the server while a user is actively viewing a WordPress page. This is ideal for triggering background tasks, such as synchronizing data with external services like Salesforce, without requiring user interaction or constant polling from the client.
We can hook into the Heartbeat API to initiate Salesforce data retrieval or updates. Crucially, the Heartbeat requests originate from the logged-in user’s browser, and the server-side PHP code handles the secure API calls to Salesforce. This prevents sensitive credentials from ever being exposed to the client.
Registering a Heartbeat Callback:
<?php
/**
* Plugin Name: My Salesforce Integration
* Description: Integrates Salesforce CRM with WordPress using Heartbeat API.
* Version: 1.0
* Author: Your Name
*/
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Include Composer's autoloader
require_once plugin_dir_path( __FILE__ ) . 'vendor/autoload.php';
use MyPlugin\Salesforce\SalesforceAuthenticator;
class My_Salesforce_Integration {
private $sf_authenticator;
private $sf_api_client; // A hypothetical client for making Salesforce API calls
private $access_token_cache_key = 'my_plugin_sf_access_token';
private $access_token_expiry_cache_key = 'my_plugin_sf_access_token_expiry';
public function __construct() {
// --- Salesforce Configuration ---
define('MY_PLUGIN_SALESFORCE_CONSUMER_KEY', 'YOUR_SALESFORCE_CONSUMER_KEY');
define('MY_PLUGIN_SALESFORCE_PRIVATE_KEY_PATH', WP_CONTENT_DIR . '/secure/salesforce_private.key'); // Ensure this path is correct and secure
define('MY_PLUGIN_SALESFORCE_LOGIN_URL', 'https://login.salesforce.com'); // Or your sandbox URL
define('MY_PLUGIN_SALESFORCE_USERNAME', '[email protected]'); // Salesforce integration user
try {
$this->sf_authenticator = new SalesforceAuthenticator(
MY_PLUGIN_SALESFORCE_CONSUMER_KEY,
MY_PLUGIN_SALESFORCE_PRIVATE_KEY_PATH,
MY_PLUGIN_SALESFORCE_LOGIN_URL,
MY_PLUGIN_SALESFORCE_USERNAME
);
// Initialize your Salesforce API client here, passing the authenticator or obtained token.
// $this->sf_api_client = new SalesforceApiClient($this->sf_authenticator);
} catch (\InvalidArgumentException $e) {
error_log("Salesforce Authenticator initialization error: " . $e->getMessage());
// Optionally, disable Salesforce features if initialization fails.
$this->sf_authenticator = null;
}
// Hook into the Heartbeat API
add_filter( 'heartbeat_received', [ $this, 'handle_heartbeat_data' ], 10, 2 );
add_filter( 'heartbeat_settings', [ $this, 'modify_heartbeat_settings' ] );
// Example: Hook to trigger a specific Salesforce action
add_action( 'my_plugin_sync_salesforce_contacts', [ $this, 'sync_salesforce_contacts' ] );
}
/**
* Modify Heartbeat settings to adjust interval if needed.
* Default is 60 seconds. For near real-time, you might reduce it,
* but be mindful of server load and Salesforce API limits.
*/
public function modify_heartbeat_settings( $settings ) {
// Only run heartbeat on specific admin pages or for logged-in users
// if ( ! is_admin() ) {
// return $settings;
// }
// $settings['interval'] = 30; // e.g., 30 seconds
return $settings;
}
/**
* Handles data received from the Heartbeat API.
*
* @param array $response The response data.
* @param array $data The data sent from the client.
* @return array Modified response data.
*/
public function handle_heartbeat_data( $response, $data ) {
// Check if our plugin is interested in this heartbeat request
if ( isset( $data['my_plugin_action'] ) ) {
switch ( $data['my_plugin_action'] ) {
case 'sync_contacts':
$response['my_plugin_sync_status'] = $this->sync_salesforce_contacts();
break;
case 'get_recent_leads':
$response['my_plugin_recent_leads'] = $this->get_recent_leads_from_salesforce();
break;
// Add more cases for other actions
}
}
return $response;
}
/**
* Retrieves or refreshes the Salesforce access token.
* Caches the token for a short period.
*
* @return string|WP_Error The access token or WP_Error on failure.
*/
private function get_cached_or_new_access_token() {
if ( ! $this->sf_authenticator ) {
return new WP_Error('sf_not_initialized', __('Salesforce integration not initialized.'));
}
$cached_token = get_transient($this->access_token_cache_key);
$expiry_time = get_transient($this->access_token_expiry_cache_key);
// Check if token exists, is not expired, and is still valid for a few minutes
if ($cached_token && $expiry_time && time() < $expiry_time - 300) { // Keep a 5-minute buffer
return $cached_token;
}
// Token is expired or missing, get a new one
$token_data = $this->sf_authenticator->get_access_token();
if (is_wp_error($token_data)) {
error_log("Failed to get Salesforce access token: " . $token_data->get_error_message());
return $token_data;
}
if (isset($token_data['access_token']) && isset($token_data['instance_url'])) {
// Salesforce token expiry is typically 2 hours (7200 seconds)
$token_expiry_seconds = $token_data['expires_in'] ?? 7200;
$new_expiry_time = time() + $token_expiry_seconds;
set_transient($this->access_token_cache_key, $token_data['access_token'], $token_expiry_seconds);
set_transient($this->access_token_expiry_cache_key, $new_expiry_time, $token_expiry_seconds);
return $token_data['access_token'];
}
return new WP_Error('sf_token_missing', __('Salesforce access token not found in response.'));
}
/**
* Example function to sync Salesforce contacts to WordPress.
* This would typically be a more complex operation involving
* checking for existing records, updating, and creating new ones.
*
* @return string Status message.
*/
public function sync_salesforce_contacts() {
$access_token = $this->get_cached_or_new_access_token();
if (is_wp_error($access_token)) {
return sprintf(__('Error getting Salesforce token: %s', 'my-plugin-textdomain'), $access_token->get_error_message());
}
// Assuming $this->sf_authenticator->salesforce_login_url contains the base URL
// and we have a way to get the instance_url from the token response or another call.
// For simplicity, let's assume instance_url is stored or retrieved.
// In a real scenario, you'd get instance_url from the token response.
$instance_url = MY_PLUGIN_SALESFORCE_LOGIN_URL; // Placeholder, get actual instance_url
// A better approach is to store instance_url along with the token.
// Let's assume get_cached_or_new_access_token also returns instance_url or it's retrievable.
// For now, we'll hardcode a common pattern or assume it's available.
// A robust solution would involve storing instance_url in transient as well.
$sf_instance_url = get_transient('my_plugin_sf_instance_url');
if (!$sf_instance_url) {
// If not cached, fetch it. This might require another token request or a dedicated call.
// For this example, we'll use a placeholder. In production, fetch it properly.
$sf_instance_url = str_replace('login.salesforce.com', 'my.salesforce.com', MY_PLUGIN_SALESFORCE_LOGIN_URL); // Example
}
$api_endpoint = trailingslashit($sf_instance_url) . 'services/data/v58.0/query/'; // Use appropriate API version
// Example SOQL query to get contacts modified in the last 24 hours
$soql_query = urlencode("SELECT Id, FirstName, LastName, Email, LastModifiedDate FROM Contact WHERE LastModifiedDate >= " . date('Y-m-d\TH:i:s\Z', strtotime('-1 day')));
$url = $api_endpoint . '?q=' . $soql_query;
$response = wp_remote_get($url, [
'headers' => [
'Authorization' => 'Bearer ' . $access_token,
'Content-Type' => 'application/json',
],
'timeout' => 60, // Increase timeout for potentially large data fetches
]);
if (is_wp_error($response)) {
error_log("Salesforce Contact Sync WP_Error: " . $response->get_error_message());
return sprintf(__('Error fetching contacts from Salesforce: %s', 'my-plugin-textdomain'), $response->get_error_message());
}
$status_code = wp_remote_retrieve_response_code($response);
$response_body = wp_remote_retrieve_body($response);
$data = json_decode($response_body, true);
if ($status_code !== 200 || !isset($data['records'])) {
$error_message = isset($data['message']) ? $data['message'] : 'Unknown error';
error_log("Salesforce Contact Sync Failed. Status: {$status_code}, Response: {$response_body}");
return sprintf(__('Salesforce contact sync failed: %s', 'my-plugin-textdomain'), $error_message);
}
// Process the fetched records
$processed_count = 0;
foreach ($data['records'] as $contact) {
// Here you would implement logic to:
// 1. Check if a contact with this Salesforce ID already exists in WordPress.
// 2. If exists, update it.
// 3. If not, create a new WordPress user/post/custom entry.
// Example:
// $existing_user = get_users(['meta_key' => 'salesforce_id', 'meta_value' => $contact['Id']]);
// if (empty($existing_user)) {
// wp_insert_user([...]); // Create new user
// } else {
// wp_update_user([...]); // Update existing user
// }
$processed_count++;
}
return sprintf(__('Successfully synced %d contacts from Salesforce.', 'my-plugin-textdomain'), $processed_count);
}
/**
* Example function to fetch recent leads from Salesforce.
*
* @return array|WP_Error An array of leads or WP_Error on failure.
*/
public function get_recent_leads_from_salesforce() {
$access_token = $this->get_cached_or_new_access_token();
if (is_wp_error($access_token)) {
return $access_token; // Return WP_Error to be handled by the caller
}
// Similar logic as sync_salesforce_contacts to get instance_url
$sf_instance_url = get_transient('my_plugin_sf_instance_url');
if (!$sf_instance_url) {
$sf_instance_url = str_replace('login.salesforce.com', 'my.salesforce.com', MY_PLUGIN_SALESFORCE_LOGIN_URL); // Example
}
$api_endpoint = trailingslashit($sf_instance_url) . 'services/data/v58.0/query/'; // Use appropriate API version
// Example SOQL query to get leads modified in the last 7 days
$soql_query = urlencode("SELECT Id, FirstName, LastName, Company, Email, LastModifiedDate FROM Lead WHERE LastModifiedDate >= " . date('Y-m-d\TH:i:s\Z', strtotime('-7 days')) . " ORDER BY LastModifiedDate DESC LIMIT 10");
$url = $api_endpoint . '?q=' . $soql_query;
$response = wp_remote_get($url, [
'headers' => [
'Authorization' => 'Bearer ' . $access_token,
'Content-Type' => 'application/json',
],
'timeout' => 30,
]);
if (is_wp_error($response)) {
error_log("Salesforce Recent Leads Fetch WP_Error: " . $response->get_error_message());
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
$response_body = wp_remote_retrieve_body($response);
$data = json_decode($response_body, true);
if ($status_code !== 200 || !isset($data['records'])) {
$error_message = isset($data['message']) ? $data['message'] : 'Unknown error';
error_log("Salesforce Recent Leads Fetch Failed. Status: {$status_code}, Response: {$response_body}");
return new WP_Error('sf_lead_fetch_failed', sprintf(__('Salesforce lead fetch failed: %s', 'my-plugin-textdomain'), $error_message));
}
return $data['records'];
}
// --- Other methods for interacting with Salesforce ---
}
// Instantiate the main plugin class
if ( class_exists( 'My_Salesforce_Integration' ) ) {
$my_salesforce_integration = new My_Salesforce_Integration();
}
?>
Client-Side JavaScript for Heartbeat Interaction
To trigger these server-side actions from the WordPress admin area, you’ll need to enqueue a JavaScript file and use the wp.heartbeat.connect() and wp.heartbeat.send() functions. This JavaScript will run in the user’s browser.
Enqueueing the JavaScript:
<?php
// Add this to your My_Salesforce_Integration class's __construct method:
// Enqueue the JavaScript file for the admin area
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_scripts' ] );
// ... inside the class ...
public function enqueue_admin_scripts() {
// Only load on specific admin pages if necessary
// if ( ! isset( $_GET['page'] ) || 'my-plugin-settings' !== $_GET['page'] ) {
// return;
// }
wp_enqueue_script(
'my-plugin-heartbeat',
plugin_dir_url( __FILE__ ) . 'js/heartbeat-handler.js',
[ 'jquery', 'heartbeat' ], // Dependencies: jQuery and WordPress Heartbeat API
'1.0',
true // Load in footer
);
// Localize script to pass PHP data to JavaScript if needed
// wp_localize_script( 'my-plugin-heartbeat', 'myPluginData', [
// 'ajax_url' => admin_url( 'admin-ajax.php' ),
// 'nonce' => wp_create_nonce( 'my-plugin-heartbeat-nonce' ),
// ] );
}
?>
JavaScript File (js/heartbeat-handler.js):
jQuery(document).ready(function($) {
// Ensure the heartbeat is enabled and we have access to wp.heartbeat
if (typeof wp !== 'undefined' && typeof wp.heartbeat !== 'undefined') {
// Start the heartbeat connection
wp.heartbeat.connect({
interval: 60000 // 60 seconds, can be adjusted or set via PHP filter
});
// Handle data received from the server via heartbeat
$(document).on('heartbeat-tick', function(e, data) {
// console.log('Heartbeat tick received:', data);
// Example: Check for sync status
if (data.my_plugin_sync_status) {
console.log('Salesforce Sync Status:', data.my_plugin_sync_status);
// Optionally display this status to the user
// $('#salesforce-sync-status').text(data.my_plugin_sync_status);
}
// Example: Display recent leads
if (data.my_plugin_recent_leads) {
console.log('Recent Salesforce Leads:', data.my_plugin_recent_leads);
// Process and display leads in a UI element
// var leadsHtml = '';
// data.my_plugin_recent_leads.forEach(function(lead) {
// leadsHtml += '<p>' + lead.FirstName + ' ' + lead.LastName + ' (' + lead.Company + ')</p>';
// });
// $('#recent-leads-list').html(leadsHtml);
}
});
// Send custom data to the server on heartbeat tick
$(document).on('heartbeat-send', function(e, data) {
// Add custom data to be sent to the server
// This data will be available in the $data parameter of the 'heartbeat_received' filter in PHP
data.my_plugin_action = 'sync_contacts'; // Trigger a contact sync
// data.my_plugin_action = 'get_recent_leads'; // Trigger fetching recent leads
// console.log('Sending heartbeat data:', data);
});
// Handle heartbeat connection errors
$(document).on('heartbeat-error', function(e, error) {
console.error('Heartbeat error:', error);
// Handle errors, e.g., show a message to the user
});
// Example: Manually trigger a sync on a button click
$('#sync-salesforce-button').on('click', function(e) {
e.preventDefault();
// Send a specific action to the server immediately
wp.heartbeat.send({
action: 'my_plugin_sync_salesforce_contacts', // This is a custom AJAX action, not heartbeat tick
my_plugin_action: 'sync_contacts' // This is the data sent *within* a heartbeat tick
});
console.log('Manual sync triggered.');
});
} else {
console.error('WordPress Heartbeat API not available.');
}
});
In the JavaScript, wp.heartbeat.connect() establishes the connection. The heartbeat-send event allows us to add custom data to the outgoing request, which the server can then process. The heartbeat-tick event receives data back from the server. Note the distinction between sending data *within* a heartbeat tick (using data.my_plugin_action) and triggering an immediate AJAX request (using wp.heartbeat.send({ action: '...' }), which would require a separate wp_ajax_ hook in PHP).
Security Considerations and Best Practices
- Private Key Security: Never commit your private key to version control. Store it in a location inaccessible via the web (e.g., outside the WordPress root directory) and set strict file permissions (
chmod 400). - Salesforce Integration User: Use a dedicated Salesforce integration user with the minimum necessary permissions. Avoid using a full administrator account.
- API Versioning: Always specify the Salesforce API version in your requests (e.g.,
v58.0) and keep it updated. - Error Handling & Logging: Implement comprehensive error handling and logging on both the WordPress and Salesforce sides. Use
error_log()in PHP for server-side logging. - Rate Limiting: Be mindful of Salesforce API limits. The Heartbeat API’s default interval (60 seconds) is generally safe, but avoid excessively frequent or complex requests. Implement server-side logic to prevent duplicate operations.
- Token Caching: Cache Salesforce access tokens and their expiry times using WordPress transients to reduce the number of JWT exchanges and API calls. Ensure the cache duration aligns with the token’s actual expiry.
- HTTPS: