Skip to content

WordPress

This guide walks through building a WordPress plugin that gates posts by category, checks access from the browser using the Latch API, shows a paywall card when access is denied, and tracks pageview events. The plugin uses only the publishable key — no server-side SDK required.

  • A running WordPress site
  • A running Latch instance
  • A product with a paid price
  • A metered paywall rule matching your premium post URLs
  • A publishable key (pk_...)
  • your Latch API configured to allow requests from your WordPress origin

If you haven’t done these yet, follow the Quickstart first.

Make sure your WordPress site is installed and that you can add a custom plugin to wp-content/plugins.

Create the following file structure inside your wp-content directory:

wp-content/
plugins/
latch-wordpress-demo/
latch-wordpress-demo.php
assets/
latch-wordpress-demo.js
latch-wordpress-demo.css

wp-content/plugins/latch-wordpress-demo/latch-wordpress-demo.php registers settings, adds a settings page, enqueues the JS and CSS on single post pages, passes config to the JS via wp_add_inline_script, and wraps article content with containers for the meter banner and paywall card.

<?php
/**
* Plugin Name: Latch WordPress Demo
* Description: Minimal Latch integration for premium WordPress posts.
* Version: 0.0.1
*/
if (!defined('ABSPATH')) {
exit;
}
function latch_demo_register_settings() {
register_setting('latch_demo', 'latch_demo_api_url');
register_setting('latch_demo', 'latch_demo_publishable_key');
register_setting('latch_demo', 'latch_demo_subscribe_url');
register_setting('latch_demo', 'latch_demo_premium_category', array(
'default' => 'premium',
));
}
add_action('admin_init', 'latch_demo_register_settings');
function latch_demo_add_settings_page() {
add_options_page(
'Latch',
'Latch',
'manage_options',
'latch-demo',
'latch_demo_render_settings_page'
);
}
add_action('admin_menu', 'latch_demo_add_settings_page');
function latch_demo_render_settings_page() {
?>
<div class="wrap">
<h1>Latch Settings</h1>
<form method="post" action="options.php">
<?php settings_fields('latch_demo'); ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="latch_demo_api_url">API URL</label></th>
<td><input class="regular-text" id="latch_demo_api_url" name="latch_demo_api_url" type="url" value="<?php echo esc_attr(get_option('latch_demo_api_url', 'https://your-latch-api.example.com')); ?>" /></td>
</tr>
<tr>
<th scope="row"><label for="latch_demo_publishable_key">Publishable key</label></th>
<td><input class="regular-text" id="latch_demo_publishable_key" name="latch_demo_publishable_key" type="text" value="<?php echo esc_attr(get_option('latch_demo_publishable_key', '')); ?>" /></td>
</tr>
<tr>
<th scope="row"><label for="latch_demo_subscribe_url">Subscribe URL</label></th>
<td><input class="regular-text" id="latch_demo_subscribe_url" name="latch_demo_subscribe_url" type="url" value="<?php echo esc_attr(get_option('latch_demo_subscribe_url', home_url('/subscribe'))); ?>" /></td>
</tr>
<tr>
<th scope="row"><label for="latch_demo_premium_category">Premium category slug</label></th>
<td><input class="regular-text" id="latch_demo_premium_category" name="latch_demo_premium_category" type="text" value="<?php echo esc_attr(get_option('latch_demo_premium_category', 'premium')); ?>" /></td>
</tr>
</table>
<?php submit_button(); ?>
</form>
</div>
<?php
}
function latch_demo_enqueue_assets() {
if (!is_single()) {
return;
}
$post = get_post();
if (!$post) {
return;
}
$premium_slug = get_option('latch_demo_premium_category', 'premium');
$is_premium = has_category($premium_slug, $post);
wp_enqueue_style(
'latch-wordpress-demo',
plugin_dir_url(__FILE__) . 'assets/latch-wordpress-demo.css',
array(),
'0.0.1'
);
wp_enqueue_script(
'latch-wordpress-demo',
plugin_dir_url(__FILE__) . 'assets/latch-wordpress-demo.js',
array(),
'0.0.1',
true
);
$config = array(
'apiUrl' => get_option('latch_demo_api_url', 'https://your-latch-api.example.com'),
'publishableKey' => get_option('latch_demo_publishable_key', ''),
'subscribeUrl' => get_option('latch_demo_subscribe_url', home_url('/subscribe')),
'isPremium' => $is_premium,
'postId' => (string) $post->ID,
'postTitle' => get_the_title($post),
'canonicalUrl' => get_permalink($post),
);
wp_add_inline_script(
'latch-wordpress-demo',
'window.latchWordPressDemo = ' . wp_json_encode($config) . ';',
'before'
);
}
add_action('wp_enqueue_scripts', 'latch_demo_enqueue_assets');
function latch_demo_article_wrapper($content) {
if (!is_single() || !in_the_loop() || !is_main_query()) {
return $content;
}
return '<div class="latch-demo-article" id="latch-demo-article">' . $content . '</div><div id="latch-demo-meter"></div><div id="latch-demo-paywall"></div>';
}
add_filter('the_content', 'latch_demo_article_wrapper');

Key points:

  • The script and stylesheet are only enqueued on single post pages (is_single()).
  • isPremium is determined by whether the post has the configured category slug.
  • The config object is injected as window.latchWordPressDemo before the main script runs.
  • The the_content filter wraps the article body and appends #latch-demo-meter and #latch-demo-paywall containers.

wp-content/plugins/latch-wordpress-demo/assets/latch-wordpress-demo.js handles anonymous identity, event tracking, access checks, meter rendering, and paywall rendering.

const config = window.latchWordPressDemo || {};
const STORAGE_KEY = "latch_wordpress_demo_anon_id";
function getAnonymousId() {
let id = localStorage.getItem(STORAGE_KEY);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(STORAGE_KEY, id);
}
return id;
}
async function trackEvent(eventType, properties = {}) {
if (!config.publishableKey) return;
try {
await fetch(`${config.apiUrl}/api/v1/events`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": config.publishableKey,
},
body: JSON.stringify({
events: [
{
eventType,
properties,
anonymousId: getAnonymousId(),
},
],
}),
});
} catch {
// Ignore tracking errors in the demo plugin.
}
}
async function checkAccess() {
if (!config.publishableKey) {
return { granted: true, reason: "error_fallback" };
}
const params = new URLSearchParams({
url: config.canonicalUrl || window.location.href,
anonymousId: getAnonymousId(),
});
try {
const response = await fetch(`${config.apiUrl}/api/v1/access/check?${params}`, {
headers: { "X-API-Key": config.publishableKey },
});
if (!response.ok) {
return { granted: true, reason: "error_fallback" };
}
return response.json();
} catch {
return { granted: true, reason: "error_fallback" };
}
}
function renderMeter(result) {
if (result.meterRemaining === undefined) return;
const meter = document.getElementById("latch-demo-meter");
if (!meter) return;
meter.innerHTML = `
<div class="latch-demo-meter-banner">
<span>${result.meterRemaining} free premium post${result.meterRemaining === 1 ? "" : "s"} remaining.</span>
<a href="${config.subscribeUrl || '#'}">Subscribe</a>
</div>
`;
}
function renderDenied(result) {
const article = document.getElementById("latch-demo-article");
const paywall = document.getElementById("latch-demo-paywall");
if (!article || !paywall) return;
article.classList.add("is-latch-preview");
paywall.innerHTML = `
<section class="latch-demo-paywall-card">
<p class="latch-demo-paywall-label">Premium post</p>
<h2>${result.paywallRule?.action?.message || "Subscribe to continue reading"}</h2>
<p>This WordPress demo keeps checkout on your server. Connect the button to your own checkout flow.</p>
<a class="latch-demo-button" href="${config.subscribeUrl || '#'}">Subscribe</a>
</section>
`;
}
async function init() {
await trackEvent("pageview", { path: window.location.pathname, postId: config.postId });
if (!config.isPremium) {
await trackEvent("article_view", { postId: config.postId, premium: false, granted: true });
return;
}
const result = await checkAccess();
renderMeter(result);
if (!result.granted) {
renderDenied(result);
}
await trackEvent("article_view", { postId: config.postId, premium: true, granted: result.granted });
}
init();

Key points:

  • Fail-open pattern: if the API is unreachable or returns an error, checkAccess returns { granted: true, reason: 'error_fallback' }. This ensures readers are never locked out by an infrastructure failure.
  • Anonymous identity: a random UUID is stored in localStorage and sent as anonymousId with events and access checks. This is how Latch tracks metered usage per visitor.
  • Events: POST /api/v1/events with the X-API-Key header set to the publishable key.
  • Access check: GET /api/v1/access/check with url and anonymousId as query parameters and the X-API-Key header.
  • Meter banner: rendered when meterRemaining is present in the access response, showing remaining free views.
  • Paywall card: rendered when granted is false, truncating the article with a CSS class and displaying a subscribe prompt.

wp-content/plugins/latch-wordpress-demo/assets/latch-wordpress-demo.css handles the meter banner, paywall card, and article truncation styles. You can customize these to match your theme.

.latch-demo-meter-banner,
.latch-demo-paywall-card {
border-radius: 22px;
border: 1px solid rgba(15, 34, 53, 0.12);
}
.latch-demo-meter-banner {
display: flex;
justify-content: space-between;
gap: 16px;
margin: 1.25rem 0;
padding: 0.95rem 1.15rem;
background: #173450;
color: #fff;
}
.latch-demo-meter-banner a {
color: #ffd7c0;
}
.latch-demo-article {
position: relative;
}
.latch-demo-article.is-latch-preview {
max-height: 18rem;
overflow: hidden;
}
.latch-demo-article.is-latch-preview::after {
content: "";
position: absolute;
inset: auto 0 0 0;
height: 7rem;
background: linear-gradient(180deg, rgba(248, 244, 237, 0), rgba(248, 244, 237, 1));
}
.latch-demo-paywall-card {
margin-top: 1.5rem;
padding: 1.5rem;
background: linear-gradient(140deg, rgba(255, 249, 244, 0.96), rgba(245, 250, 255, 0.96));
}
.latch-demo-paywall-label {
margin: 0 0 0.5rem;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 0.72rem;
color: #8b4a27;
}
.latch-demo-paywall-card h2 {
margin: 0 0 0.75rem;
}
.latch-demo-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8rem 1.2rem;
border-radius: 999px;
background: #b5562a;
color: #fff;
text-decoration: none;
}
  1. In the WordPress admin, go to Plugins and activate Latch WordPress Demo.
  2. Go to Settings > Latch.
  3. Set the following:
    • API URL: your Latch API base URL
    • Publishable key: your pk_... key from the Latch dashboard
    • Subscribe URL: your checkout page URL
    • Premium category slug: premium
  1. Go to Posts > Categories and create a category called premium.
  2. Create a few posts — assign the premium category to the ones you want gated.
  3. Create at least one post without the premium category as a free article.

Your Latch API must allow requests from the WordPress origin used by your site.

  1. Open a free post — it should render normally with no access check.
  2. Open a premium post — the first few visits should be granted (metered), with meterRemaining shown in the meter banner.
  3. After exceeding the meter limit, the article should be truncated and a paywall card shown.
  4. Check the Latch dashboard — events should appear in the overview counters.
  • The plugin only runs on single post pages (is_single()).
  • It detects premium posts by checking if the post has the configured category slug.
  • The JavaScript uses only browser-safe publishable-key endpoints (GET /api/v1/access/check and POST /api/v1/events).
  • Checkout is handled by your server (where the secret key is safe) — the subscribe button links to your own checkout flow.
  • Metered rules track views per anonymous visitor per URL pattern using a rolling 30-day window.
  • Keep the subscribe URL pointing to your own checkout flow. The demo plugin links to it but does not implement checkout itself.
  • Soft paywall rules return granted: true — inspect paywallRule in the access response if you want to show a prompt for soft rules.
  • The URL strategy matters: keep premium URLs predictable so your Latch paywall rule matches them easily (e.g. URLs containing /premium/ or a specific category path).
  • For production, consider caching the access check result briefly to reduce API calls.