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.
Prerequisites
Section titled “Prerequisites”- Docker installed (for running WordPress locally)
- A running Latch instance (API on
http://localhost:4000, Dashboard onhttp://localhost:3000) - A product with a paid price
- A metered paywall rule matching your premium post URLs
- A publishable key (
pk_...) CORS_ORIGINon the Latch API set to includehttp://localhost:8090
If you haven’t done these yet, follow the Quickstart first.
Step 1: Start WordPress
Section titled “Step 1: Start WordPress”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:
docker compose up -dOpen http://localhost:8090 and complete the WordPress installer.
Step 2: Create the plugin
Section titled “Step 2: Create the plugin”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.cssPlugin PHP file
Section titled “Plugin PHP file”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()). isPremiumis determined by whether the post has the configured category slug.- The config object is injected as
window.latchWordPressDemobefore the main script runs. - The
the_contentfilter wraps the article body and appends#latch-demo-meterand#latch-demo-paywallcontainers.
Plugin JavaScript
Section titled “Plugin JavaScript”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,
checkAccessreturns{ granted: true, reason: 'error_fallback' }. This ensures readers are never locked out by an infrastructure failure. - Anonymous identity: a random UUID is stored in
localStorageand sent asanonymousIdwith events and access checks. This is how Latch tracks metered usage per visitor. - Events:
POST /api/v1/eventswith theX-API-Keyheader set to the publishable key. - Access check:
GET /api/v1/access/checkwithurlandanonymousIdas query parameters and theX-API-Keyheader. - Meter banner: rendered when
meterRemainingis present in the access response, showing remaining free views. - Paywall card: rendered when
grantedisfalse, truncating the article with a CSS class and displaying a subscribe prompt.
Plugin CSS
Section titled “Plugin CSS”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;}Step 3: Activate and configure
Section titled “Step 3: Activate and configure”- In the WordPress admin, go to Plugins and activate Latch WordPress Demo.
- Go to Settings > Latch.
- 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
- API URL:
Step 4: Create content
Section titled “Step 4: Create content”- Go to Posts > Categories and create a category called
premium. - Create a few posts — assign the
premiumcategory to the ones you want gated. - Create at least one post without the
premiumcategory as a free article.
Step 5: Configure CORS
Section titled “Step 5: Configure CORS”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:8090Restart the API if needed.
Step 6: Verify the flow
Section titled “Step 6: Verify the flow”- Open a free post — it should render normally with no access check.
- Open a premium post — the first few visits should be granted (metered), with
meterRemainingshown in the meter banner. - After exceeding the meter limit, the article should be truncated and a paywall card shown.
- Check the Latch dashboard — events should appear in the overview counters.
How it works
Section titled “How it works”- 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/checkandPOST /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— inspectpaywallRulein 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.