Put your club’s internal clubhouse instructions, volunteer handbook, or meeting information behind a WordPress sign-in. A linked member can open the page during their recorded membership period. Signed-out visitors, unlinked accounts, and people outside that period see a clear explanation instead of the protected text.
This is an external API integration running on your WordPress server. WordPress owns the website account, page, and session; campai supplies the membership dates used for the access decision. Use a hosted Developer App for an administrative tool inside campai, such as the welcome board.
What you’ll have at the end
A WordPress handbook that opens only for linked accounts whose current campai membership dates allow access.
- Website account signs in
- WordPress resolves the saved contact link
- Server checks campai membership dates
- Private handbook or a closed-access message
Before you start
- WordPress administrator access, PHP 8.2+, and a private staging website
- A key with Mandates Read and Members Read, restricted to one mandate
- Two verified subscriber accounts and two synthetic member contacts
On this page
This example protects one page of static text. Start with a short handbook containing headings, paragraphs, lists, and ordinary links. A PDF or image uploaded to the WordPress Media Library keeps its own public URL; putting its link on a private page does not secure the file. Use a separate protected-download service when the file itself is confidential.
The access rule is explicit: the linked contact must be a member with a valid entry date on or before today, and no exit date or an exit date on or after today. Both boundary days count. Today comes from Settings → General → Timezone in WordPress. A missing entry date, unavailable field, contradictory interval, or failed API lookup keeps the page closed.
This rule does not check paid dues, subscriptions, group membership, or bookings. Decide whether membership dates are the right requirement for the content you chose before using it with real members.
You need a WordPress administrator account that can install custom plugins, PHP 8.2 or newer, and HTTPS on the site you will publish. Use a private staging site for the first run.
- Create two subscriber accounts you control. Keep a separate administrator account for setup.
- Create two synthetic campai member contacts with different membership dates.
- Create an API key with Mandates → Read and Members → Read, including entry and exit dates. Restrict it to your chosen mandate.
- Find the full organization and mandate IDs and the full contact IDs for those two members.
- Verify which website account belongs to which member. An email match alone is not proof of account ownership.
Do not test access only as a WordPress administrator. Administrators and editors have native rights to private WordPress pages that ordinary subscribers do not have.
Download the source at the end of this guide. Put campai-member-content.php in wp-content/plugins/campai-member-content/, then activate campai member content under Plugins. You can also upload the ZIP through Plugins → Add Plugin → Upload Plugin.
The plugin adds Settings → campai member content and a verified-contact field on user profiles. It is self-contained: the earlier membership-profile plugin, Composer, and an SDK are not required.
Create two different WordPress pages:
| Page | Visibility and content |
|---|---|
| Club handbook source | Private. Put all protected text here. |
| Member resources | Public. Add a Shortcode block containing [campai_member_content]. Only public introductory text belongs beside it. |
Copy each page’s numeric ID from its editor URL, such as post=123. In the plugin settings, enter the organization ID, mandate ID, API key, Gateway page ID, and Private source page ID, then choose Save connection.
The settings reject a source page that is not private. While the plugin is active, it also keeps that configured source private if an editor tries to publish or schedule it. If the plugin is deactivated, WordPress still protects the private source; the public gateway contains only the shortcode, never a copy of the protected text.
The source renderer supports static page HTML. It does not execute nested shortcodes, dynamic blocks, or another plugin’s content filters. Start with ordinary text blocks and inspect the result before adding more formatting.
Open Users → All Users, edit the first subscriber, and find campai member content. Enter that person’s full contact ID under Verified contact ID, then save the user. Repeat for the second subscriber with its own contact ID.
Only an administrator can save these links. The reader cannot choose a contact in the URL or by changing form fields. Clearing the field removes the link. Changing the configured organization or mandate invalidates existing links until you verify and save them again.
The key stays on the WordPress server. The settings form never displays the stored key; leaving its field blank keeps the current value. Saving the configuration alone does not contact campai. The first linked subscriber’s page request tests the connection.
Exclude the gateway’s actual permalink and query variants from the WordPress page cache, hosting cache, and CDN cache. Purge any copies already stored. Do this before putting protected text into use.
The plugin sends no-cache headers for the gateway, but a cache may respond before WordPress runs. Inspect the deployed response headers and test both accounts separately. A cached page must never give one visitor another visitor’s authorized response.
The plugin makes a fresh API request on each eligible gateway visit. It does not reuse a previous successful decision when the API fails or membership dates change.
- Open Member resources while signed out. Select Sign in.
- Sign in as the linked subscriber whose membership is current.
- The page shows the subscriber’s website display name, the handbook content, and Sign out.
- Sign out and use the second account, whose entry date is in the future or exit date is in the past. The protected content must disappear.
- Clear a subscriber’s mapping as the administrator and reload its page. It should ask for the account to be linked again.
Changing the membership period in campai takes effect on the next uncached request. A page already open in the browser does not erase text someone has already received. This example controls delivery; it cannot prevent an authorized reader from copying the content.
| Check | Expected result |
|---|---|
| Entry or exit date is today | The linked subscriber can read the page. |
| Entry is tomorrow or exit was yesterday | The protected text is absent from the HTML response. |
| Entry is missing or the key cannot read the date fields | Access remains closed with a verification message. |
| API timeout, invalid key, or rate limit | Previous success is not reused. |
| Add another contact ID to the gateway URL | The account remains linked to its saved contact. |
| Open the private source’s direct URL as a subscriber | Native WordPress private-page protection applies. |
Request the private source through /wp-json/wp/v2/pages/PAGE_ID while signed out or as a subscriber |
No private content is returned. |
| Open search results, a feed, embed, preview, or the gateway REST response | The shortcode does not expand the handbook. |
| Disable the plugin on staging | The source stays private; the gateway does not contain its protected text. |
Also inspect the page’s HTML and network requests. The browser should receive no API key, full contact object, or direct request to campai. Test these routes with your actual theme, cache, and other plugins because they can add their own content delivery paths.
Use the same IDs and key from the settings in this private terminal request. The endpoint returns the contact directly. Verify its identity, member type, and the presence of both entryAt and exitAt; do not interpret a field you cannot read as an empty value.
Example
curl --fail-with-body --silent --show-error --max-time 30 --globoff \
--request GET 'https://cloud.campai.com/api/YOUR_ORGANIZATION_ID/YOUR_MANDATE_ID/crm/contacts/YOUR_CONTACT_ID' \
--header 'X-API-Key: YOUR_API_KEY'The cURL request prints the complete record in your private terminal. Node.js and PHP validate the mapped contact and print only the two dates. Replace both occurrences of YOUR_CONTACT_ID in those tabs. These diagnostics do not grant access to a page; the plugin applies the complete date rule on your server.
If this request works but the gateway stays closed, inspect the subscriber’s saved link, the WordPress time zone, both selected page IDs, and the source’s Private visibility. WordPress’s HTTP API performs the server request; its no-cache headers are separate from your hosting cache configuration. The native Pages REST interface is another route to include in your access tests.
The plugin is PHP because it runs in WordPress. The diagnostic request above has cURL, Node.js, and PHP tabs. Copy the complete PHP file or install the ZIP; the installation notes are included.
The complete member-content plugin
Upload the ZIP through WordPress Plugins, or copy the PHP file into wp-content/plugins/campai-member-content/. Activate it, configure two pages, and link your test subscriber accounts.
Download ZIPView and copy the complete source
The complete member-content plugin
<?php
/**
* Plugin Name: campai member content
* Description: Serve a private WordPress page to explicitly linked accounts with a verified current campai membership.
* Version: 1.0.0
* Requires PHP: 8.2
* License: GPL-2.0-or-later
*/
if (!defined('ABSPATH')) exit;
function campai_content_config() {
return wp_parse_args(get_option('campai_content_settings', []), [
'organization' => '', 'mandate' => '', 'key' => '', 'gateway' => 0, 'source' => 0,
]);
}
function campai_content_scope($config) {
return hash('sha256', $config['organization'] . ':' . $config['mandate']);
}
function campai_content_id($value) {
return is_string($value) && preg_match('/^[a-f0-9]{24}$/iD', $value);
}
function campai_content_post_text($name) {
$value = $_POST[$name] ?? '';
if (!is_string($value)) wp_die('Expected a text value.', '', ['response' => 400]);
return trim(wp_unslash($value));
}
add_action('admin_menu', function () {
add_options_page('campai member content', 'campai member content', 'manage_options', 'campai-member-content', 'campai_content_settings_page');
});
add_action('admin_post_campai_content_settings', function () {
if (!current_user_can('manage_options')) wp_die('Administrator access required.', '', ['response' => 403]);
check_admin_referer('campai_content_settings');
$config = campai_content_config();
$organization = strtolower(campai_content_post_text('organization'));
$mandate = strtolower(campai_content_post_text('mandate'));
$key = campai_content_post_text('api_key');
if (!campai_content_id($organization) || !campai_content_id($mandate)) wp_die('Use full 24-character organization and mandate IDs.');
if ($key === '') $key = $config['key'];
if ($key === '' || strlen($key) > 4096 || preg_match('/[\x00-\x20\x7f]/', $key)) wp_die('Enter the complete API key without spaces.');
$gateway = campai_content_page_id(campai_content_post_text('gateway'));
$source = campai_content_page_id(campai_content_post_text('source'));
$gateway_post = get_post($gateway);
$source_post = get_post($source);
if ($gateway === $source || !$gateway_post || $gateway_post->post_type !== 'page' || $gateway_post->post_status !== 'publish' ||
!$source_post || $source_post->post_type !== 'page' || $source_post->post_status !== 'private') {
wp_die('Choose two different pages: a published gateway and a private source page.');
}
update_option('campai_content_settings', compact('organization', 'mandate', 'key', 'gateway', 'source'), false);
wp_safe_redirect(admin_url('options-general.php?page=campai-member-content&saved=1'));
exit;
});
function campai_content_page_id($value) {
if (!is_string($value) || !preg_match('/^[1-9][0-9]{0,9}$/D', $value)) wp_die('Enter a valid page ID.');
return (int) $value;
}
function campai_content_settings_page() {
if (!current_user_can('manage_options')) return;
$config = campai_content_config();
?>
<div class="wrap">
<h1>campai member content</h1>
<p>Connect WordPress to one campai organization and mandate. Only an administrator can link accounts.</p>
<?php if (isset($_GET['saved'])): ?><div class="notice notice-success"><p>Settings saved. Verify the account links and test the gateway as a subscriber and signed-out visitor.</p></div><?php endif; ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="campai_content_settings">
<?php wp_nonce_field('campai_content_settings'); ?>
<table class="form-table" role="presentation">
<tr><th><label for="campai-organization">Organization ID</label></th><td><input class="regular-text" id="campai-organization" name="organization" required pattern="[a-fA-F0-9]{24}" value="<?php echo esc_attr($config['organization']); ?>"></td></tr>
<tr><th><label for="campai-mandate">Mandate ID</label></th><td><input class="regular-text" id="campai-mandate" name="mandate" required pattern="[a-fA-F0-9]{24}" value="<?php echo esc_attr($config['mandate']); ?>"></td></tr>
<tr><th><label for="campai-key">API key</label></th><td><input class="regular-text" type="password" id="campai-key" name="api_key" autocomplete="new-password" value=""><p class="description"><?php echo $config['key'] ? 'A key is stored. Leave blank to keep it, or enter a replacement.' : 'Enter a key with Mandates Read and Members Read. It stays on your server.'; ?></p></td></tr>
<tr><th><label for="campai-gateway">Gateway page ID</label></th><td><input type="number" min="1" required id="campai-gateway" name="gateway" value="<?php echo esc_attr($config['gateway']); ?>"><p class="description">A published page containing only the [campai_member_content] shortcode and public introductory text.</p></td></tr>
<tr><th><label for="campai-source">Private source page ID</label></th><td><input type="number" min="1" required id="campai-source" name="source" value="<?php echo esc_attr($config['source']); ?>"><p class="description">Keep the protected text on a separate page with Private visibility. Use static text, headings, lists, and links.</p></td></tr>
</table>
<?php submit_button('Save connection'); ?>
</form>
<h2>Finish the setup</h2>
<ol><li>Open Users and edit the intended website account.</li><li>Enter its verified campai contact ID in the campai member content section.</li><li>Add <code>[campai_member_content]</code> to the published gateway page. Put protected text only on the private source page.</li><li>Exclude the gateway from every page cache and CDN cache, including query variants. Purge existing cached copies before testing.</li></ol>
<p>This protects the selected page’s text. Media-library files and external links need their own access controls. The API key is stored in the WordPress options table. Restrict database and backup access. Deactivating this plugin does not revoke the key.</p>
</div>
<?php
}
function campai_content_user_fields($user) {
if (!current_user_can('manage_options') || !current_user_can('edit_user', $user->ID)) return;
$binding = get_user_meta($user->ID, '_campai_content_binding', true);
$binding = is_array($binding) ? $binding : [];
wp_nonce_field('campai_content_user_' . $user->ID, 'campai_content_user_nonce');
?>
<h2>campai member content</h2>
<table class="form-table" role="presentation"><tr><th><label for="campai-contact">Verified contact ID</label></th><td>
<input class="regular-text" id="campai-contact" name="campai_contact" pattern="[a-fA-F0-9]{24}" value="<?php echo esc_attr($binding['contact'] ?? ''); ?>">
<p class="description">Link only after verifying this account belongs to that person. Leave blank to remove access. Saving binds the contact to the currently configured organization and mandate.</p>
</td></tr></table>
<?php
}
add_action('show_user_profile', 'campai_content_user_fields');
add_action('edit_user_profile', 'campai_content_user_fields');
function campai_content_save_user($user_id) {
if (!current_user_can('manage_options') || !current_user_can('edit_user', $user_id)) return;
if (!isset($_POST['campai_content_user_nonce']) || !is_string($_POST['campai_content_user_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['campai_content_user_nonce'])), 'campai_content_user_' . $user_id)) return;
$contact = campai_content_post_text('campai_contact');
if ($contact === '') { delete_user_meta($user_id, '_campai_content_binding'); return; }
if (!campai_content_id($contact)) wp_die('Use a full 24-character contact ID.');
$config = campai_content_config();
if (!campai_content_id($config['organization']) || !campai_content_id($config['mandate'])) wp_die('Save the campai connection first.');
update_user_meta($user_id, '_campai_content_binding', ['contact' => strtolower($contact), 'scope' => campai_content_scope($config)]);
}
add_action('personal_options_update', 'campai_content_save_user');
add_action('edit_user_profile_update', 'campai_content_save_user');
// Keep the source private if an editor tries to schedule or publish it while this plugin is active.
add_filter('wp_insert_post_data', function ($data, $postarr) {
$config = campai_content_config();
if ((int) ($postarr['ID'] ?? 0) === (int) $config['source'] && $config['source'] > 0 &&
in_array($data['post_status'], ['publish', 'future'], true)) $data['post_status'] = 'private';
return $data;
}, 10, 2);
add_action('template_redirect', function () {
$config = campai_content_config();
if ($config['gateway'] > 0 && is_page((int) $config['gateway'])) {
if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true);
nocache_headers();
}
});
// WordPress can otherwise remove a subscriber's preview flag and redirect to the live page.
add_filter('redirect_canonical', function ($redirect) {
$config = campai_content_config();
return isset($_GET['preview']) && $config['gateway'] > 0 && is_page((int) $config['gateway']) ? false : $redirect;
});
function campai_content_day($value) {
if (!is_string($value) || !preg_match('/^(\d{4}-\d{2}-\d{2})T00:00:00(?:\.000)?Z$/D', $value, $match)) return null;
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $match[1], new DateTimeZone('UTC'));
return $date && $date->format('Y-m-d') === $match[1] ? $match[1] : null;
}
function campai_content_access() {
if (!is_user_logged_in()) return 'signed_out';
$config = campai_content_config();
$binding = get_user_meta(get_current_user_id(), '_campai_content_binding', true);
if (!is_array($binding) || !campai_content_id($binding['contact'] ?? null) ||
!is_string($binding['scope'] ?? null) || !hash_equals(campai_content_scope($config), $binding['scope'])) return 'unlinked';
if ($config['key'] === '' || !campai_content_id($config['organization']) || !campai_content_id($config['mandate'])) return 'unavailable';
// A fresh server-side check for this linked account. No visitor-controlled host or contact ID.
$url = 'https://cloud.campai.com/api/' . $config['organization'] . '/' . $config['mandate'] . '/crm/contacts/' . $binding['contact'];
$response = wp_remote_get($url, [
'headers' => ['X-API-Key' => $config['key']],
'timeout' => 10, 'redirection' => 0, 'limit_response_size' => 262144,
]);
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) return 'unavailable';
$contact = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($contact) || ($contact['_id'] ?? null) !== $binding['contact'] ||
!is_array($contact['types'] ?? null) || !in_array('member', $contact['types'], true)) return 'unavailable';
$member = $contact['member'] ?? null;
if (!is_array($member) || !array_key_exists('entryAt', $member) || !array_key_exists('exitAt', $member)) return 'unavailable';
$entry = campai_content_day($member['entryAt']);
$exit = $member['exitAt'] === null ? null : campai_content_day($member['exitAt']);
// Missing, invalid, or contradictory dates cannot establish eligibility.
if ($entry === null || ($member['exitAt'] !== null && $exit === null) || ($exit !== null && $exit < $entry)) return 'unavailable';
$today = current_datetime()->format('Y-m-d'); // WordPress Settings > General > Timezone.
return $entry <= $today && ($exit === null || $exit >= $today) ? 'allowed' : 'outside_interval';
}
function campai_content_message($title, $message, $login = false) {
$html = '<section class="campai-member-content wp-block-group has-global-padding"><h2>' . esc_html($title) . '</h2><p>' . esc_html($message) . '</p>';
if ($login) $html .= '<div class="wp-block-buttons"><div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="' . esc_url(wp_login_url(get_permalink())) . '">Sign in</a></div></div>';
return $html . '</section>';
}
function campai_content_shortcode() {
$config = campai_content_config();
// REST, feeds, excerpts, previews, and other pages never expand private text.
if (is_admin() || wp_doing_ajax() || (defined('REST_REQUEST') && REST_REQUEST) || is_feed() || is_embed() || is_preview() || isset($_GET['preview']) ||
!is_singular('page') || (int) get_queried_object_id() !== (int) $config['gateway'] || !$config['gateway']) {
return '<p>Open the club’s member content page to sign in.</p>';
}
$source = get_post((int) $config['source']);
if (!$source || $source->post_type !== 'page' || $source->post_status !== 'private' || $source->ID === (int) $config['gateway']) {
return campai_content_message('Member content is unavailable', 'Please contact your club administrator.');
}
$access = campai_content_access();
if ($access === 'signed_out') return campai_content_message('Your club’s member resources', 'Sign in with your linked website account to open the member resources.', true);
if ($access === 'unlinked') return campai_content_message('Link your membership first', 'Ask your club administrator to verify and link this website account.');
if ($access === 'outside_interval') return campai_content_message('Membership access is not active', 'This page is available between the recorded membership entry and exit dates, including both days.');
if ($access !== 'allowed') return campai_content_message('Membership could not be checked', 'The resources remain closed. Please try again later or contact your club administrator.');
// Render static content only: no nested shortcodes, dynamic blocks, or the_content filters.
$content = wp_kses_post(wpautop(strip_shortcodes($source->post_content)));
return '<section class="campai-member-content wp-block-group has-global-padding"><p>Signed in as ' . esc_html(wp_get_current_user()->display_name) . '.</p>' . $content . '<p><a href="' . esc_url(wp_logout_url(get_permalink())) . '">Sign out</a></p></section>';
}
add_shortcode('campai_member_content', 'campai_content_shortcode');
Check your result
- Current member, then future member
- Only the current member receives handbook text.
- Private source, REST, feed, search, embed, and preview URLs
- Protected source text stays out of subscriber and signed-out responses.
- Revoke the key after a successful visit
- The next visit closes access without reusing the previous result.