Plausible Custom Events + Revenue Tracking: Self-Hosted Setup
Plausible Cloud charges $19/month extra for Funnels. Self-hosted Community Edition gives you the same data for free — the events are already sitting in your ClickHouse instance, you just need to query them. Same story for revenue tracking: the schema has revenue_source_amount and revenue_source_currency columns sitting empty until you send the right payload. Both features ship “off by default” because Plausible’s privacy posture pushes you to opt-in to anything beyond pageviews.
This guide walks the full custom-events stack on self-hosted Plausible: event taxonomy, the three-field revenue payload, Stripe webhook wiring, WordPress form integration, funnel reconstruction via ClickHouse SQL, and the four pitfalls that silently drop conversion data. No Cloud upgrade required. Prerequisite: Plausible CE 2.x running — if you haven’t deployed yet, start with the Plausible CE on Hetzner: 20-minute self-host setup.
Why Plausible’s pageview-only default is a feature
A fresh Plausible install tracks one thing: pageviews. Every signup, every checkout, every “Download v2.3” click is invisible until you ship custom events. The default dashboard isn’t broken — it’s a privacy posture choice. Pageviews need no opt-in because they don’t carry user-specific intent. Custom events do, so they require explicit goals registered in the admin UI before they appear anywhere.
Practical consequence: events fired without a registered goal are stored in ClickHouse but never surface in the dashboard. They aren’t lost — you can query them directly — but the UI silently filters them out. This is a frequent debugging trap, covered in the pitfalls section below.
The plausible() function: three forms you’ll use
The entire client API is one function with three call patterns:
// 1. Bare event — just register it happened
window.plausible('Signup');
// 2. Event with custom properties (max ~30 props per site, string or number)
window.plausible('Signup', {
props: {
plan: 'pro',
method: 'google_oauth',
trial_days: 14
}
});
// 3. Event with revenue + props
window.plausible('Purchase', {
props: { plan: 'pro_annual', coupon: 'LAUNCH20' },
revenue: { currency: 'EUR', amount: 49.00 }
});
For form submissions where the event needs to fire before navigation, add a callback:
window.plausible('Lead', {
props: { form: 'contact' },
callback: () => { window.location = '/thanks'; }
});
The tracker script must include the revenue variant in its filename for revenue tracking to work, otherwise the field is silently dropped. The full canonical script tag for an e-commerce setup:
<script defer
data-domain="example.com"
src="https://plausible.example.com/js/script.outbound-links.file-downloads.revenue.tagged-events.js"></script>
<script>
window.plausible = window.plausible || function() {
(window.plausible.q = window.plausible.q || []).push(arguments)
};
</script>
The script-name segments are additive and order-independent. script.outbound-links.file-downloads.revenue.tagged-events.js bundles auto-tracking for outbound clicks, file downloads, declarative HTML events, and revenue support — the realistic e-commerce baseline.
Event taxonomy: name them once, regret them never
Event names are case-sensitive strings, not normalized. Signup, signup, and Sign up are three separate events as far as Plausible is concerned. Renaming a goal does not retroactively rename historical events — you lose the history under the old name. This makes naming convention a one-shot decision.
Convention worth adopting: short, capitalized verb-noun pairs. Signup, Purchase, Plan-Upgrade, Download, Outbound-Click. Avoid descriptors like clicked_blue_button_pricing — the prop is for that:
// Bad: hardcoded into name — can't aggregate "all clicks"
plausible('clicked_blue_button_pricing');
// Good: aggregatable, props for slicing
plausible('CTA-Click', { props: { variant: 'blue', page: '/pricing' } });
Soft cap: 20 to 30 distinct event names per site before the dashboard gets noisy. If you need more, you probably want a product-analytics tool like PostHog, not Plausible.
Goals: registering events for the dashboard
Custom events appear in the dashboard only after registration as Goals. Site Settings → Goals → + Add Goal. Three goal types:
- Pageview goal — exact path or wildcard pattern (
/thanks,/blog/**) - Custom event goal — exact event name match, case-sensitive
- File download / Outbound link — auto-detected if the corresponding tracker variant is loaded
For revenue to display in the dashboard, the goal must be a Custom Event Goal with the “Measure revenue for this goal” toggle enabled. Without that toggle, revenue payloads land in ClickHouse but the UI shows zero.
Revenue tracking: the three-field payload
The revenue object accepts exactly two fields:
plausible('Purchase', {
revenue: {
currency: 'EUR', // ISO 4217, uppercase
amount: 49.00 // total order amount, NOT unit price
}
});
// COUNTER-INTUITIVE
amount: "29.00" as a string silently fails — Plausible drops the revenue field, the event still records as a goal completion. About a third of self-hosted revenue dashboards are empty for exactly this reason. Always pass amount as a number, never quoted.
Other constraints worth knowing before you ship:
currencymust be an uppercase ISO 4217 code (USD, EUR, GBP, JPY). Lowercase or made-up codes (eur,EURO) cause the revenue field to drop while keeping the event.- Plausible converts to your reporting currency server-side using daily exchange rates — multi-currency stores work without per-event normalization on your side.
- One currency per goal is the cleanest pattern. If you sell in EUR and USD, register two goals (
Purchase EUR,Purchase USD) rather than mixing currencies on one goal. amountis total order value (quantity × price − discounts), not unit price. Custom backends often pass unit price by accident, undercounting revenue by N times.
Stripe webhook to Plausible: server-side recipe
Client-side revenue tracking on a thank-you page works for happy-path purchases but misses refunds, chargebacks, subscription renewals, and any payment that completes outside the browser session (mobile app, retry-after-failure, manual invoicing). The fix is a Stripe webhook that posts directly to Plausible’s ingestion API.
The endpoint is POST /api/event, public (no API token required — it’s the same endpoint the browser uses). Three headers are mandatory:
Content-Type: application/jsonUser-Agent— required, missing UA = bot rejection, event silently droppedX-Forwarded-For— the end-user’s IP for daily-salt visitor hashing and geolocation. Without it, every server-side event resolves to one “user” with the server’s IP
Node.js / Express handler:
// /webhooks/stripe.js
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET);
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
if (event.type === 'checkout.session.completed') {
const s = event.data.object;
await fetch('https://plausible.example.com/api/event', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'StripeWebhook/1.0',
'X-Forwarded-For': s.customer_details?.address?.country || '0.0.0.0'
},
body: JSON.stringify({
name: 'Purchase',
url: `https://example.com/checkout/success?cs=${s.id}`,
domain: 'example.com',
revenue: {
currency: s.currency.toUpperCase(),
amount: s.amount_total / 100 // Stripe sends cents, Plausible wants major units
},
props: {
plan: s.metadata?.plan || 'unknown',
email_domain: s.customer_details?.email?.split('@')[1] || ''
}
})
});
}
res.json({ received: true });
}
);
Refund handling: Plausible CE has no delete API. The workaround is firing a follow-up event with negative amount when charge.refunded arrives. ClickHouse aggregates correctly because revenue sums treat negatives normally:
// On charge.refunded webhook
plausibleEvent({
name: 'Refund',
revenue: { currency: 'EUR', amount: -49.00 }
});
Register a separate Refund goal in the admin UI to keep refunds visible separately from gross purchases. The same pattern works for Paddle (transaction.completed + transaction.refunded) and LemonSqueezy (order_created + order_refunded) — the payload-mapping is the only thing that changes.
// NEXT STEP
If you’re moving more tracking server-side — including the cookieless analytics layer — the broader pattern is covered in 5 self-hosted cookieless tracking recipes. Plausible is recipe #1 there; the same Stripe-webhook pattern works as the revenue layer for Matomo, Umami, and PostHog.
WordPress and form events without GTM
Most WordPress tutorials assume Google Tag Manager as the event router. With Plausible self-hosted, you don’t need it. The official Plausible Analytics WordPress plugin handles tracker injection, reverse-proxy mode (which masks the endpoint from ad-blockers), and WooCommerce purchase events with revenue out of the box. For most WP sites, that plugin is the entire integration.
For non-WooCommerce custom events — form submissions, lead magnets, newsletter signups — the manual pattern is twelve lines:
// functions.php or mu-plugin
add_action('wpcf7_mail_sent', function($contact_form) {
$form_title = $contact_form->title();
?>
<script>
plausible('Lead', {
props: { form: }
});
</script>
<?php
});
For Gravity Forms, swap wpcf7_mail_sent for gform_after_submission. For native HTML forms with no plugin, attach a submit handler in your theme footer:
document.querySelectorAll('form[data-track]').forEach(form => {
form.addEventListener('submit', () => {
plausible('Form-Submit', {
props: { form: form.dataset.track }
});
});
});
Then mark the forms you want to track with data-track="contact-page". If you want a more comprehensive tag-routing layer for non-form events, the 7 self-hosted tag manager alternatives guide covers Matomo Tag Manager and Cloudflare Zaraz as drop-in GTM replacements.
Funnels via ClickHouse SQL: the $19/month bypass
Plausible Cloud’s Funnels feature is a UI wrapper on top of ClickHouse aggregation queries. Self-hosted CE doesn’t expose the UI, but it doesn’t lock the data either — you can run the same queries directly. The trade-off: you write SQL, you maintain queries when the schema evolves, and you lose the dashboard polish.
Connect to ClickHouse from the host running your Plausible compose stack:
docker compose exec plausible_events_db clickhouse-client
Three-step funnel example (pricing page → signup → purchase, last 30 days):
WITH funnel_users AS (
SELECT user_id,
maxIf(timestamp, name = 'pageview' AND pathname = '/pricing') AS t_pricing,
maxIf(timestamp, name = 'Signup') AS t_signup,
maxIf(timestamp, name = 'Purchase') AS t_purchase
FROM events_v2
WHERE site_id = <YOUR_SITE_ID>
AND timestamp >= now() - INTERVAL 30 DAY
GROUP BY user_id
)
SELECT
countIf(t_pricing > 0) AS step1_pricing,
countIf(t_pricing > 0 AND t_signup > t_pricing) AS step2_signup,
countIf(t_signup > 0 AND t_purchase > t_signup) AS step3_purchase
FROM funnel_users;
Important caveat: user_id in Plausible is a daily-rotated salted hash, not a stable cross-day identifier. This funnel query is reliable only within a single 24-hour window. For cross-day funnels, you need to pass a stable backend user identifier as a custom prop (props: { user_id: hashedEmail }) and join on that.
Two more SQL patterns worth saving for later:
-- Revenue by UTM source, last 30 days
SELECT
utm_source,
count() AS purchases,
sum(revenue_reporting_amount) AS revenue
FROM events_v2
WHERE site_id = ? AND name = 'Purchase'
AND timestamp >= today() - 30
GROUP BY utm_source
ORDER BY revenue DESC;
-- Top entry pages by Signup conversion rate
WITH entry_pages AS (
SELECT session_id, argMin(pathname, timestamp) AS entry_path
FROM events_v2
WHERE site_id = ? AND name = 'pageview'
AND timestamp >= today() - 30
GROUP BY session_id
),
sessions_with_signup AS (
SELECT DISTINCT session_id
FROM events_v2
WHERE site_id = ? AND name = 'Signup'
AND timestamp >= today() - 30
)
SELECT e.entry_path,
count() AS sessions,
countIf(s.session_id IS NOT NULL) AS signups,
round(countIf(s.session_id IS NOT NULL) * 100.0 / count(), 2) AS conv_rate_pct
FROM entry_pages e
LEFT JOIN sessions_with_signup s USING(session_id)
GROUP BY entry_path
HAVING sessions >= 50
ORDER BY conv_rate_pct DESC
LIMIT 20;
For dashboard-style consumption, point Grafana or Metabase at the ClickHouse instance directly. That’s a follow-up topic; the queries above are the substrate.
Four pitfalls that silently drop conversion data
1. Tracker variant mismatch. Calling plausible('Purchase', { revenue: {...} }) without .revenue.js in the script URL silently drops the revenue field. The event still records as a goal completion. DevTools Network tab shows 202 Accepted regardless — there’s no error to grep for. First debugging step when revenue dashboards stay empty: check the script URL.
2. Goal name case mismatch. Code fires plausible('signup'), the registered Goal in the admin UI is Signup. Plausible doesn’t normalize, doesn’t trim. Events accumulate in ClickHouse under the wrong key, the goal stays at zero, and the only place you can confirm this is a SQL query against events_v2.
3. Reverse-proxy stripping X-Forwarded-For. When you proxy Plausible behind Caddy or nginx to bypass ad-blockers, the visitor IP must propagate through the proxy. Without it, every visitor hashes to the same user_id — your unique-visitor count collapses to one. Caddy passes X-Forwarded-For by default; nginx requires proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; explicitly.
4. Ad-blockers stripping the event endpoint. The default plausible.io endpoint is on every blocker’s filter list. Self-hosting on a custom subdomain (stats.yourdomain.com) is necessary but not sufficient — the script URL must also be relative or first-party. Reverse-proxy both /js/script.js and /api/event through your main domain (snippet in the cookieless recipes pillar, Recipe 1). Block-rate drops from ~25% to ~3% in tech-savvy audiences after this change.
Quick-pick cheatsheet
The most common events with their canonical payloads:
| Event | Trigger | Props | Revenue |
|---|---|---|---|
Signup | form submit | {plan, source} | — |
Purchase | Stripe success / webhook | {plan, billing} | {currency, amount} |
Refund | charge.refunded webhook | {plan, reason} | {currency, -amount} |
Plan-Upgrade | subscription.updated | {from, to} | {currency, amount} |
Lead | contact form submit | {form} | — |
Download | auto via .file-downloads.js | {file} | — |
Outbound-Click | auto via .outbound-links.js | {url} | — |
CTA-Click | data-attr or JS | {variant, page} | — |
Register each as a Custom Event Goal in Site Settings → Goals before firing — otherwise events accumulate in ClickHouse but never appear in the dashboard.
FAQ
Can I track revenue without a webhook, client-side only? Yes, by firing plausible('Purchase', { revenue: {...} }) on a thank-you page. It works for happy-path purchases but misses refunds, chargebacks, subscription renewals, and any payment that completes outside the browser session. For accurate revenue dashboards, use the Stripe webhook pattern in section 7.
Does Plausible CE support multi-currency revenue? Yes. Pass any ISO 4217 currency code uppercase, Plausible converts to your reporting currency server-side using daily exchange rates. Mixing currencies on a single goal causes UI display issues — use one goal per currency for clean reporting.
How many custom events can I have? No hard limit on event volume, but a practical cardinality ceiling around 20 to 30 distinct event names per site before the dashboard becomes hard to read. Per-event prop count is around 30. If you need more dimensions, you’ve outgrown Plausible — consider PostHog or Matomo. For Matomo specifically, see Matomo Ecommerce Tracking 2026: Self-Hosted Setup Guide for the equivalent revenue + cart + funnel stack.
Does Funnels work in Plausible CE? The UI feature is Cloud-only (Business plan, $19/month extra). The underlying data is in ClickHouse on your self-hosted instance, queryable directly via SQL as shown in section 9. Cloud’s Funnels is a UI wrapper on those same queries.
Can I backfill historical events from Stripe? Yes, on self-hosted CE, by firing events with a historical timestamp via the Events API. This isn’t possible on Plausible Cloud (timestamps are clamped to “now”). Useful for migrating existing Stripe data into a new Plausible instance.
// NEXT STEPS
Now you have events, revenue, and funnels for $0 extra. Two natural next steps: deeper privacy posture (no consent banner) via 5 self-hosted cookieless tracking recipes, or replacing GTM entirely if your tag stack is growing — 7 self-hosted tag manager alternatives.
Found this useful?
Try the Stack Picker to get a personal recommendation, or browse the install recipe library.