How to securely integrate Salesforce CRM endpoints into WordPress custom plugins using Metadata API (add_post_meta)
Leveraging Salesforce Metadata API for Secure WordPress CRM Integration
Integrating external CRM systems with WordPress, particularly for e-commerce operations, demands a robust and secure approach. This post details a method for securely exposing and interacting with Salesforce CRM endpoints from within custom WordPress plugins, focusing on the use of the Salesforce Metadata API and WordPress’s `add_post_meta` function for data persistence. This strategy is crucial for maintaining data integrity and security when synchronizing customer, order, or product information between your WordPress site and Salesforce.
Prerequisites and Setup
Before diving into the code, ensure you have the following:
- A Salesforce Developer Edition account or a Salesforce production account with API access enabled.
- A Salesforce Connected App configured to allow API access (OAuth 2.0 is recommended). Note down the Consumer Key and Consumer Secret.
- A WordPress installation with administrative access.
- Basic understanding of PHP, WordPress plugin development, and Salesforce object model.
Salesforce API Authentication Strategy
Directly embedding Salesforce credentials within a WordPress plugin is a significant security risk. We will employ OAuth 2.0 for secure authentication. The WordPress plugin will act as a client, initiating an OAuth flow to obtain an access token. For simplicity in this example, we’ll outline a server-to-server (JWT Bearer Flow) or a web server flow, but the core principle is to obtain and securely store the access token and refresh token.
A more production-ready approach would involve storing these tokens securely, potentially in WordPress options or a dedicated secure storage, and implementing token refresh mechanisms. For this demonstration, we’ll simulate token retrieval and focus on the interaction with the Metadata API.
Interacting with Salesforce Metadata API via PHP
The Salesforce Metadata API allows you to retrieve, deploy, create, update, or delete customization information, including custom objects and fields. We’ll use it to dynamically fetch information about Salesforce objects that we intend to synchronize with WordPress. This is more flexible than hardcoding object API names.
First, let’s set up a basic PHP class to handle Salesforce API interactions. This class will encapsulate authentication and API call logic.
Salesforce API Client Class
This class will manage the connection and basic API requests. For a real-world scenario, consider using a robust Salesforce SDK for PHP.
`SalesforceApiClient.php`
<?php
/**
* Salesforce API Client for WordPress Integration.
*
* Handles authentication and basic API requests.
* For production, consider a more robust SDK and secure token management.
*/
class SalesforceApiClient {
private $instance_url;
private $access_token;
private $api_version = 'v58.0'; // Use a recent API version
// Salesforce Connected App credentials (should be stored securely, e.g., wp-config.php constants or encrypted options)
private $client_id; // Consumer Key
private $client_secret; // Consumer Secret
private $username; // Salesforce username for authentication
private $password; // Salesforce password + security token
private $grant_type; // e.g., 'password' for username/password flow, 'urn:ietf:params:oauth:grant-type:jwt-bearer' for JWT
public function __construct($config) {
// Load configuration securely
$this->client_id = $config['client_id'];
$this->client_secret = $config['client_secret'];
$this->username = $config['username'];
$this->password = $config['password'];
$this->grant_type = $config['grant_type'] ?? 'password'; // Default to password grant for simplicity
// For JWT Bearer Flow, you'd need private_key, issuer, subject, etc.
// This example uses username/password for simplicity, which is NOT recommended for production.
// A production system should use JWT Bearer Flow or Web Server Flow.
$this->authenticate();
}
private function authenticate() {
$auth_url = 'https://login.salesforce.com/services/oauth2/token'; // Use test.salesforce.com for sandbox
$params = [
'grant_type' => $this->grant_type,
'client_id' => $this->client_id,
'client_secret' => $this->client_secret,
'username' => $this->username,
'password' => $this->password,
];
// For JWT Bearer Flow, params would be different:
// 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
// 'assertion' => $jwt_token
$response = wp_remote_post($auth_url, [
'body' => $params,
'timeout' => 60, // Increased timeout for authentication
]);
if (is_wp_error($response)) {
error_log("Salesforce Auth Error: " . $response->get_error_message());
return false;
}
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if (isset($data['access_token']) && isset($data['instance_url'])) {
$this->access_token = $data['access_token'];
$this->instance_url = $data['instance_url'];
return true;
} else {
error_log("Salesforce Auth Failed: " . print_r($data, true));
return false;
}
}
public function make_request($method, $endpoint, $data = []) {
if (!$this->access_token || !$this->instance_url) {
error_log("Salesforce API not authenticated.");
return new WP_Error('salesforce_auth_error', 'Salesforce API not authenticated.');
}
$url = untrailingslashit($this->instance_url) . '/services/data/' . $this->api_version . $endpoint;
$headers = [
'Authorization' => 'Bearer ' . $this->access_token,
'Content-Type' => 'application/json',
];
$args = [
'method' => strtoupper($method),
'headers' => $headers,
'timeout' => 90, // Increased timeout for API calls
];
if (!empty($data) && ($method === 'POST' || $method === 'PATCH' || $method === 'PUT')) {
$args['body'] = json_encode($data);
}
$response = wp_remote_request($url, $args);
if (is_wp_error($response)) {
error_log("Salesforce API Request Error: " . $response->get_error_message());
return $response;
}
$body = wp_remote_retrieve_body($response);
$status_code = wp_remote_retrieve_response_code($response);
if ($status_code >= 200 && $status_code < 300) {
return json_decode($body, true);
} else {
error_log("Salesforce API Error ({$status_code}): " . $body);
return new WP_Error('salesforce_api_error', 'Salesforce API Error', ['response' => $body, 'status' => $status_code]);
}
}
// Example: Get metadata for a specific object (e.g., Account)
public function get_object_metadata($object_api_name) {
// The Metadata API is typically accessed via SOAP or Tooling API for metadata objects.
// For describing objects and fields, the REST API's SObject Basic Information or SObject Row Basics is more common.
// Let's use SObject Basic Information for field discovery.
return $this->make_request('GET', "/sobjects/{$object_api_name}");
}
// Example: Get metadata for all custom objects
public function get_all_custom_objects() {
// This endpoint lists all available SObjects. Filtering for custom objects is done client-side.
return $this->make_request('GET', '/sobjects/');
}
}
?>
Securely Storing Salesforce Credentials
Never hardcode sensitive credentials directly in your plugin files. Use WordPress’s `wp-config.php` file or a more advanced secrets management system. For `wp-config.php`, define constants:
// In wp-config.php define( 'SALESFORCE_CLIENT_ID', 'YOUR_CONSUMER_KEY' ); define( 'SALESFORCE_CLIENT_SECRET', 'YOUR_CONSUMER_SECRET' ); define( 'SALESFORCE_USERNAME', '[email protected]' ); define( 'SALESFORCE_PASSWORD', 'YOUR_SALESFORCE_PASSWORD_AND_SECURITY_TOKEN' ); define( 'SALESFORCE_GRANT_TYPE', 'password' ); // Or 'urn:ietf:params:oauth:grant-type:jwt-bearer'
Integrating with WordPress Custom Plugins
Now, let’s integrate this `SalesforceApiClient` into a custom WordPress plugin. We’ll create a simple example that fetches Salesforce object metadata and stores relevant field information using `add_post_meta`.
Example Plugin Structure
Assume you have a plugin directory structure like:
/wp-content/plugins/my-salesforce-sync/
my-salesforce-sync.php
includes/
SalesforceApiClient.php
Main Plugin File (`my-salesforce-sync.php`)
<?php
/*
Plugin Name: My Salesforce Sync
Description: Integrates with Salesforce CRM endpoints.
Version: 1.0
Author: Your Name
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
// Include the Salesforce API Client
require_once plugin_dir_path( __FILE__ ) . 'includes/SalesforceApiClient.php';
// --- Salesforce Configuration ---
// Load credentials from wp-config.php
$sf_config = [
'client_id' => defined('SALESFORCE_CLIENT_ID') ? SALESFORCE_CLIENT_ID : '',
'client_secret' => defined('SALESFORCE_CLIENT_SECRET') ? SALESFORCE_CLIENT_SECRET : '',
'username' => defined('SALESFORCE_USERNAME') ? SALESFORCE_USERNAME : '',
'password' => defined('SALESFORCE_PASSWORD') ? SALESFORCE_PASSWORD : '',
'grant_type' => defined('SALESFORCE_GRANT_TYPE') ? SALESFORCE_GRANT_TYPE : 'password',
];
// --- Plugin Initialization ---
class MySalesforceSync {
private $sf_client;
public function __construct() {
global $sf_config;
// Instantiate the Salesforce client
// In a real app, handle cases where config is missing gracefully.
if ( ! empty( $sf_config['client_id'] ) && ! empty( $sf_config['username'] ) ) {
$this->sf_client = new SalesforceApiClient( $sf_config );
} else {
// Log a warning or display an admin notice if config is incomplete
add_action( 'admin_notices', [$this, 'sf_config_warning'] );
}
// Hook into WordPress actions/filters
add_action( 'admin_menu', [$this, 'add_admin_menu'] );
add_action( 'admin_init', [$this, 'settings_init'] );
// Example: Trigger sync on a specific action, e.g., a button click in admin
add_action( 'admin_post_my_sf_sync_objects', [$this, 'sync_salesforce_objects_action'] );
}
public function sf_config_warning() {
<<<HTML
<div class="notice notice-warning is-dismissible">
<p><strong>Salesforce Sync Plugin:</strong> Salesforce API credentials are not fully configured in wp-config.php. Please check your settings.</p>
</div>
HTML;
}
public function add_admin_menu() {
add_options_page(
'Salesforce Sync Settings',
'Salesforce Sync',
'manage_options',
'my-salesforce-sync',
[$this, 'render_settings_page']
);
}
public function settings_init() {
register_setting( 'mySalesforceSyncGroup', 'my_sf_sync_options', [$this, 'sanitize_options'] );
add_settings_section(
'my_sf_sync_section_general',
__( 'Salesforce Object Mapping', 'my-salesforce-sync' ),
null, // Callback for section description
'my-salesforce-sync'
);
// Example: Add a field to map a Salesforce object to a WordPress post type
add_settings_field(
'my_sf_sync_object_mapping',
__( 'Object Mapping', 'my-salesforce-sync' ),
[$this, 'render_object_mapping_field'],
'my-salesforce-sync',
'my_sf_sync_section_general'
);
}
public function render_object_mapping_field() {
$options = get_option( 'my_sf_sync_options' );
$sf_objects = $this->get_available_salesforce_objects();
if ( is_wp_error( $sf_objects ) ) {
echo '<p style="color:red;">Could not fetch Salesforce objects: ' . esc_html( $sf_objects->get_error_message() ) . '</p>';
return;
}
if ( empty( $sf_objects ) ) {
echo '<p>No Salesforce objects found or API not connected.</p>';
return;
}
// Get available WordPress post types (excluding built-in ones if desired)
$wp_post_types = get_post_types( ['public' => true, '_builtin' => false], 'objects' );
echo '<table class="widefat striped">';
echo '<thead><tr><th>Salesforce Object</th><th>Map to WordPress Post Type</th><th>Sync Fields (JSON)</th></tr></thead>';
echo '<tbody>';
foreach ( $sf_objects as $sf_object ) {
// Filter for custom objects if needed: str_ends_with($sf_object['name'], '__c')
if ( ! isset( $sf_object['name'] ) || ! isset( $sf_object['label'] ) ) continue;
$sf_object_name = esc_attr( $sf_object['name'] );
$sf_object_label = esc_html( $sf_object['label'] );
$current_mapping = isset( $options['object_mapping'][$sf_object_name] ) ? $options['object_mapping'][$sf_object_name] : [];
$mapped_wp_type = isset( $current_mapping['wp_post_type'] ) ? esc_attr( $current_mapping['wp_post_type'] ) : '';
$sync_fields_json = isset( $current_mapping['sync_fields'] ) ? esc_attr( json_encode( $current_mapping['sync_fields'] ) ) : '';
echo '<tr>';
echo '<td>' . $sf_object_label . ' (' . $sf_object_name . ')</td>';
echo '<td>';
echo '<select name="my_sf_sync_options[object_mapping][' . $sf_object_name . '][wp_post_type]">';
echo '<option value="">' . __( '-- Select --', 'my-salesforce-sync' ) . '</option>';
foreach ( $wp_post_types as $post_type_slug => $post_type_obj ) {
echo '<option value="' . esc_attr( $post_type_slug ) . '" ' . selected( $mapped_wp_type, $post_type_slug, false ) . '>' . esc_html( $post_type_obj->label ) . ' (' . esc_html( $post_type_slug ) . ')</option>';
}
echo '</select></td>';
echo '<td>';
echo '<textarea name="my_sf_sync_options[object_mapping][' . $sf_object_name . '][sync_fields]" rows="3" cols="50" placeholder=\'{"SalesforceFieldApiName": "WordPressPostMetaKey"}\'>' . $sync_fields_json . '</textarea>';
echo '</td>';
echo '</tr>';
}
echo '</tbody></table>';
echo '<p class="description">' . __( 'Map Salesforce objects to WordPress post types and specify which fields to sync. Fields should be in JSON format: {"SalesforceFieldApiName": "WordPressPostMetaKey"}.', 'my-salesforce-sync' ) . '</p>';
}
public function sanitize_options( $input ) {
$new_input = [];
if ( isset( $input['object_mapping'] ) && is_array( $input['object_mapping'] ) ) {
foreach ( $input['object_mapping'] as $sf_object_name => $mapping_data ) {
$sanitized_mapping = [];
if ( isset( $mapping_data['wp_post_type'] ) ) {
$sanitized_mapping['wp_post_type'] = sanitize_text_field( $mapping_data['wp_post_type'] );
}
if ( isset( $mapping_data['sync_fields'] ) ) {
$decoded_fields = json_decode( $mapping_data['sync_fields'], true );
if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded_fields ) ) {
$sanitized_fields = [];
foreach ( $decoded_fields as $sf_field => $wp_meta_key ) {
$sanitized_fields[sanitize_key( $sf_field )] = sanitize_key( $wp_meta_key );
}
$sanitized_mapping['sync_fields'] = $sanitized_fields;
} else {
// Handle invalid JSON, maybe log an error or clear the field
add_settings_error( 'my_sf_sync_options', 'invalid_json_fields', __( 'Invalid JSON format for sync fields.', 'my-salesforce-sync' ), 'error' );
}
}
if ( ! empty( $sanitized_mapping['wp_post_type'] ) ) { // Only save if a post type is mapped
$new_input['object_mapping'][$sf_object_name] = $sanitized_mapping;
}
}
}
return $new_input;
}
public function render_settings_page() {
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<form action="options.php" method="post">
<?php
settings_fields( 'mySalesforceSyncGroup' );
do_settings_sections( 'my-salesforce-sync' );
submit_button();
?>
</form>
<hr>
<h2>Manual Sync</h2>
<p>Trigger a manual sync of Salesforce objects to WordPress.</p>
<form method="post" action="">
<input type="hidden" name="action" value="my_sf_sync_objects">
<?php wp_nonce_field( 'my_sf_sync_objects_nonce' ); ?>
<?php submit_button( 'Sync Salesforce Objects Now' ); ?>
</form>
</div>
<?php
}
/**
* Fetches available Salesforce objects using the API.
* Caches the result for a short period to avoid excessive API calls.
*
* @return array|WP_Error
*/
private function get_available_salesforce_objects() {
if ( ! $this->sf_client ) {
return new WP_Error('sf_client_not_initialized', 'Salesforce client not initialized.');
}
$cache_key = 'my_sf_sync_salesforce_objects';
$cached_data = get_transient( $cache_key );
if ( false !== $cached_data ) {
return $cached_data;
}
$objects = $this->sf_client->get_all_custom_objects(); // This actually returns all objects, not just custom ones.
if ( is_wp_error( $objects ) ) {
return $objects;
}
// Filter for custom objects if needed (conventionally ends with __c)
// Or, if you want to sync standard objects like Account, Contact, remove this filter.
$custom_objects = array_filter($objects['sobjects'], function($obj) {
return isset($obj['custom']) && $obj['custom'] === true;
// Or to include standard objects: return true;
});
set_transient( $cache_key, $custom_objects, HOUR_IN_SECONDS * 1 ); // Cache for 1 hour
return $custom_objects;
}
/**
* Handles the manual sync action.
*/
public function sync_salesforce_objects_action() {
// Verify nonce
if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( $_POST['_wpnonce'], 'my_sf_sync_objects_nonce' ) ) {
wp_die( 'Security check failed.' );
}
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'You do not have sufficient permissions to perform this action.' );
}
if ( ! $this->sf_client ) {
wp_die( 'Salesforce client not initialized. Please check configuration.' );
}
$options = get_option( 'my_sf_sync_options' );
if ( ! isset( $options['object_mapping'] ) || empty( $options['object_mapping'] ) ) {
wp_die( 'No Salesforce object mappings configured. Please set them up in the settings page.' );
}
$sync_results = [];
foreach ( $options['object_mapping'] as $sf_object_api_name => $mapping_config ) {
if ( empty( $mapping_config['wp_post_type'] ) || empty( $mapping_config['sync_fields'] ) ) {
continue; // Skip if mapping is incomplete
}
$wp_post_type = $mapping_config['wp_post_type'];
$sync_fields = $mapping_config['sync_fields'];
// Fetch records from Salesforce (example: query for all records)
// In a real-world scenario, you'd implement incremental sync, filtering, etc.
$sf_query = "SELECT Id, " . implode(', ', array_keys($sync_fields)) . " FROM {$sf_object_api_name} LIMIT 10"; // Limit for demo
$sf_records = $this->query_salesforce_records($sf_query);
if (is_wp_error($sf_records)) {
$sync_results[$sf_object_api_name] = ['error' => $sf_records->get_error_message()];
continue;
}
if (empty($sf_records)) {
$sync_results[$sf_object_api_name] = ['status' => 'No records found to sync.'];
continue;
}
$created_count = 0;
$updated_count = 0;
foreach ($sf_records as $sf_record) {
$sf_record_id = $sf_record['Id']; // Salesforce Record ID
// Check if this record already exists in WordPress (e.g., by SF ID meta)
$args = [
'post_type' => $wp_post_type,
'meta_query' => [
[
'key' => '_salesforce_id', // Custom meta key to store SF ID
'value' => $sf_record_id,
'compare' => '=',
],
],
'posts_per_page' => 1,
'post_status' => 'any', // Check all statuses
];
$existing_posts = get_posts( $args );
$post_id = false;
if ( ! empty( $existing_posts ) ) {
$post_id = $existing_posts[0]->ID;
$updated_count++;
} else {
// Create a new post
$post_data = [
'post_title' => $sf_record['Name'] ?? $sf_object_api_name . ' ' . $sf_record_id, // Use a relevant field for title
'post_status' => 'publish',
'post_type' => $wp_post_type,
];
$post_id = wp_insert_post( $post_data, true );
if ( is_wp_error( $post_id ) ) {
error_log( "Failed to insert post for SF ID {$sf_record_id}: " . $post_id->get_error_message() );
continue; // Skip to next record
}
$created_count++;
}
// Update post meta using add_post_meta (or update_post_meta)
foreach ( $sync_fields as $sf_field_api_name => $wp_meta_key ) {
if ( isset( $sf_record[$sf_field_api_name] ) ) {
$value = $sf_record[$sf_field_api_name];
// Use add_post_meta to add or update. If the meta key doesn't exist, it adds. If it exists, it updates.
// For multiple values, you might need update_post_meta or handle it differently.
// add_post_meta($post_id, $wp_meta_key, $value, true); // The 'true' argument prevents duplicates if the key already exists.
// A more robust approach for updating existing meta:
if ( false === get_post_meta( $post_id, $wp_meta_key, true ) ) {
add_post_meta( $post_id, $wp_meta_key, $value, false ); // Add if not exists
} else {
update_post_meta( $post_id, $wp_meta_key, $value ); // Update if exists
}
}
}
// Store Salesforce ID as meta for future reference and updates
if ( false === get_post_meta( $post_id, '_salesforce_id', true ) ) {
add_post_meta( $post_id, '_salesforce_id', $sf_record_id, true );
} else {
update_post_meta( $post_id, '_salesforce_id', $sf_record_id );
}
}
$sync_results[$sf_object_api_name] = ['status' => 'Sync complete', 'created' => $created_count, 'updated' => $updated_count];
}
// Redirect back to settings page with results
$redirect_url = add_query_arg( ['page' => 'my-salesforce-sync', 'sf_sync_results' => urlencode( json_encode( $sync_results ) )], admin_url( 'options-general.php' ) );
wp_redirect( $redirect_url );
exit;
}
/**
* Executes a SOQL query against Salesforce.
*
* @param string $query The SOQL query string.
* @return array|WP_Error
*/
private function query_salesforce_records($query) {
if ( ! $this->sf_client ) {
return new WP_Error('sf_client_not_initialized', 'Salesforce client not initialized.');
}
// Use the REST API's SOQL query endpoint
$endpoint = '/query';
$params = [
'q' => $query,
];
// The make_request method handles GET requests with query parameters correctly
// For GET requests, data is usually passed as query parameters, not in the body.
// Let's adjust make_request or create a specific query method if needed.
// For now, assume make_request can handle it or we adapt.
// A better approach for GET with params:
$url = untrailingslashit($this->sf_client->instance_url) . '/services/data/' . $this->sf_client->api_version . $endpoint . '?' . http_build_query($params);
$headers = [
'Authorization' => 'Bearer ' . $this->sf_client->access_token,
'Content-Type' => 'application/json',
];
$args = [
'method' => 'GET',
'headers' => $headers,
'timeout' => 120, // Increased timeout for queries
];
$response = wp_remote_request($url, $args);
if (is_wp_error($response)) {
error_log("Salesforce Query Error: " . $response->get_error_message());
return $response;
}
$body = wp_remote_retrieve_body($response);
$status_code = wp_remote_retrieve_response_code($response);
if ($status_code >= 200 && $status_code < 300) {
$data = json_decode($body, true);
return $data['records'] ?? []; // SOQL query results are under the 'records' key
} else {
error_log("Salesforce Query API Error ({$status_code}): " . $body);
return new WP_Error('salesforce_query_api_error', 'Salesforce Query API Error', ['response' => $body, 'status' => $status_code]);
}
}
/**
* Displays sync results on the settings page.
*/
public function display_sync_results() {
if ( isset( $_GET['sf_sync_results'] ) && ! empty( $_GET['sf_sync_results'] ) ) {
$results_json = urldecode( $_GET['sf_sync_results'] );
$results = json_decode( $results_json, true );
if ( $results && is_array( $results ) ) {
echo '<div class="notice notice-success is-dismissible"><p><strong>Sync Results