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.

  • Docker installed (for running WordPress locally)
  • A running Latch instance (API on http://localhost:4000, Dashboard on http://localhost:3000)
  • A product with a paid price
  • A metered paywall rule matching your premium post URLs
  • A publishable key (pk_...)
  • CORS_ORIGIN on the Latch API set to include http://localhost:8090

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

Create a docker-compose.yml:

services:
db:
image: mariadb:11
restart: unless-stopped
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
MYSQL_ROOT_PASSWORD: wordpress
volumes:
- db_data:/var/lib/mysql
wordpress:
image: wordpress:6.8-php8.2-apache
restart: unless-stopped
depends_on:
- db
ports:
- "8090:80"
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
volumes:
- ./wp-content:/var/www/html/wp-content
volumes:
db_data:

Start the containers:

Terminal window
docker compose up -d

Open http://localhost:8090 and complete the WordPress installer.

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', 'http://localhost:4000')); ?>" /></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', 'http://localhost:4000'),
'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: http://localhost:4000
    • 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. Set the CORS_ORIGIN environment variable to include http://localhost:8090:

CORS_ORIGIN=http://localhost:3000,http://localhost:8090

Restart the API if needed.

  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.