Skip to content
$_ setuptracking
Tracking

Plausible Custom Events, Revenue & Funnels: Self-Hosted CE (2026)

22 min read
Source code with XML/HTML markup representing Plausible custom event tracking script

Plausible Cloud charges $19/month for Funnels, revenue tracking, and custom event goals. Self-hosted Community Edition gives you all three for free — the events are already in your ClickHouse instance, the revenue_reporting_amount column is sitting empty, the goals UI ships in the admin. Total wiring time: about 30 minutes. Annual savings: $228 plus whatever Cloud charges for the upgrade tier you’d otherwise need.

// PLAUSIBLE CLOUD — BUSINESS
$19/mo + base plan
  • Funnels UI
  • Revenue tracking
  • Custom event goals
  • Hosted dashboard
  • Updates auto-applied
// PLAUSIBLE CE — SELF-HOSTED
Free + ~30 min wiring
  • Funnels via ClickHouse SQL
  • Revenue (2-field payload)
  • Custom event goals (admin UI)
  • Self-hosted dashboard
  • Tracker variants you control
Same data, same features. The $19/mo is a UI wrapper on top of queries you can run yourself.

This guide is the full custom-events stack on Plausible CE: event taxonomy, custom properties, the two-field revenue object, Stripe webhooks with idempotency and refund handling, WordPress without GTM, raw event querying in ClickHouse, funnel SQL that replaces the Cloud-only Funnels UI, an honest “when this is overkill” section, and the four pitfalls that silently drop conversion data.

Prerequisite: a running Plausible CE 2.x instance. New to CE? Start with the 20-minute Plausible CE on Hetzner setup, then come back. For the broader self-hosted analytics landscape (5 tools compared), see the self-hosted analytics hero pillar; this article is the events-layer deep-dive on top of it.

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.

plausible() browser / server POST /api/event CE ingestion events_v2 ClickHouse row Goal match? dashboard filter no match: row stored, dashboard hides it events fire → rows persist → UI gates display
Event lifecycle: rows always persist in ClickHouse; the dashboard filters them through registered goals.

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 via events_v2 — but the UI silently filters them out. This is the single most common debugging trap; we cover it in section 13.

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 event, 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. Variants are pre-compiled and shipped with alphabetical segment order. The canonical e-commerce baseline:

script.file-downloads.outbound-links.revenue.tagged-events.js
file-downloadsauto-fires File Download on PDF/zip/etc clicks
outbound-linksauto-fires Outbound Link: Click on external <a href>
revenueenables revenue: { currency, amount } field on events
tagged-eventsenables declarative class="plausible-event-name=Signup" on HTML
Segments are alphabetical, period-separated. Get the order wrong and the file 404s.
<script defer
        data-domain="example.com"
        src="https://plausible.example.com/js/script.file-downloads.outbound-links.revenue.tagged-events.js"></script>
<script>
  window.plausible = window.plausible || function() {
    (window.plausible.q = window.plausible.q || []).push(arguments)
  };
</script>

The trailing inline-script is a queue shim: if an ad-blocker prevents the main script from loading, calls to plausible(...) in your code don’t throw — they queue silently. Ship it on every site.

Custom properties: collecting structured event metadata

The props object is what makes Plausible custom events analytically useful. Without props, every Signup event is identical — you know how many but nothing about which. With props, you slice signups by plan, source, oauth provider, trial length, anything you can serialize as a string or number.

  • 30 props maximum per event — not per site. Each fired event can carry up to 30 key-value pairs.
  • Values must be scalar: strings, numbers, or booleans are all accepted. Nested objects and arrays are rejected silently.
  • Length caps: event name ≤ 120 chars, URL ≤ 2000 bytes, prop name ≤ 300 chars, prop value ≤ 2000 chars. Exceed any of these and the event is dropped at ingestion.
  • Cardinality matters for the dashboard. A plan prop with 4 values (free/pro/team/enterprise) renders well. A user_id prop with 50,000 values makes the breakdown UI useless — query it via SQL instead.
  • Keys are case-sensitive. plan and Plan are two different breakdowns. Pick one convention (recommended: snake_case) and never deviate.

Practical example — a SaaS pricing page tracking which CTA variant converts:

plausible('CTA-Click', {
  props: {
    variant: 'amber-button-v3',     // dashboard breakdown: which variant wins
    page: '/pricing',                // dashboard breakdown: which page
    above_fold: 1,                   // numeric so you can sumIf in SQL
    plan_visible: 'pro_annual'
  }
});

Plausible’s docs cover the full custom event goals reference if you want to pair this with the props-based goal matching feature.

Event taxonomy: name conventions that age well

Event names are case-sensitive strings, not normalized. Signup, signup, and Sign up are three separate events. Renaming a goal does not retroactively rename historical events — you lose the history under the old name. Naming is a one-shot decision.

Convention worth adopting: short, capitalized verb-noun pairs. Signup, Purchase, Plan-Upgrade, Download, CTA-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’ve outgrown Plausible — consider PostHog or Matomo instead.

Read:  What Is Tracking in Web Analytics? A Simple Explanation

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
plausible.example.comSite SettingsGoals → + Add Goal
GOAL TYPE
Pageview Custom Event
EVENT NAME
Purchase
Measure revenue for this goal
Currency — EUR
Goal admin form: revenue toggle exposes the currency dropdown. Without it, revenue payloads land in ClickHouse but the UI displays zero.

For revenue to display, the goal must be a Custom Event Goal with the “Measure revenue for this goal” toggle enabled. Without that toggle, revenue payloads are stored but the dashboard shows zero. Plausible automatically backfills goal-matching against historical events — if you fired Purchase events before registering the goal, they show up retroactively once the goal is created.

Revenue tracking: the two-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
  }
});

Per the Plausible revenue tracking docs, the amount field accepts both a JavaScript number (49.00) and a string ("49.00") — the ingestion server parses both. What it won’t accept: trailing currency symbols, comma-decimal separators ("49,00"), or non-numeric strings.

Constraints worth knowing before you ship:

  • currency must 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.
  • amount is total order value (quantity × price − discounts), not unit price. Custom backends often pass unit price by accident, undercounting revenue by N times.

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.

Contact Form 7

The wpcf7_mail_sent hook fires after the form is successfully delivered:

// functions.php or mu-plugin
add_action('wpcf7_mail_sent', function($contact_form) {
  $form_title = $contact_form->title();
  ?>
  <script>
    if (window.plausible) {
      plausible('Lead', {
        props: { form:  }
      });
    }
  </script>
  <?php
});

The if (window.plausible) guard is what saves you when an ad-blocker has stripped the main script — without it, the inline call throws and breaks downstream JS on the thank-you page.

Gravity Forms

Gravity uses gform_after_submission; the signature is two args ($entry, $form):

add_action('gform_after_submission', function($entry, $form) {
  ?>
  <script>
    if (window.plausible) {
      plausible('Lead', {
        props: { form:  }
      });
    }
  </script>
  <?php
}, 10, 2);

Native HTML forms

For native forms with no plugin, attach a submit handler in your theme footer:

document.querySelectorAll('form[data-track]').forEach(form => {
  form.addEventListener('submit', () => {
    if (window.plausible) {
      plausible('Form-Submit', {
        props: { form: form.dataset.track }
      });
    }
  });
});

Mark the forms you want to track with data-track="contact-page". If you want a 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.

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 Events API.

USER STRIPE YOUR SERVER PLAUSIBLE CE Pay (saves IP in metadata) checkout.session.completed + stripe-signature header verify signature + idempotency check POST /api/event 2 required headers + 1 for server-side ingest Content-Type: application/json (required) User-Agent: <non-empty> (required) X-Forwarded-For: <customer IP> (server-side only) 202 Accepted (also returned for dropped events!)
Stripe → Plausible flow. Content-Type + User-Agent required; X-Forwarded-For required for server-side ingest. 202 is returned for both accepted and dropped events.

Node.js handler with idempotency

Stripe retries failed webhooks for up to 3 days. Without an idempotency check, every retry double-counts the Purchase event. The minimum-viable defense is a small KV/DB table keyed on event.id:

// /webhooks/stripe.js
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET);

// Stripe stores zero-decimal currencies (JPY, KRW, VND, ...) in major units already
// and three-decimal currencies (BHD, KWD, OMR, JOD, TND) ×1000. The default is ×100.
const ZERO_DECIMAL = new Set(['BIF','CLP','DJF','GNF','JPY','KMF','KRW','MGA','PYG','RWF','UGX','VND','VUV','XAF','XOF','XPF']);
const THREE_DECIMAL = new Set(['BHD','JOD','KWD','OMR','TND']);
function fromStripeAmount(amount, currency) {
  const c = currency.toUpperCase();
  if (ZERO_DECIMAL.has(c))  return amount;
  if (THREE_DECIMAL.has(c)) return amount / 1000;
  return amount / 100;
}

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
    );

    // Idempotency: skip if we've seen this event.id before
    if (await seenWebhook(event.id)) return res.json({ received: true });
    await markSeen(event.id);

    if (event.type === 'checkout.session.completed') {
      const s = event.data.object;
      const customerIp = s.metadata?.customer_ip || '0.0.0.0';

      await fetch('https://plausible.example.com/api/event', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'User-Agent': 'StripeWebhook/1.0 (+example.com)',
          'X-Forwarded-For': customerIp
        },
        body: JSON.stringify({
          name: 'Purchase',
          url: `https://example.com/checkout/success?cs=${s.id}`,
          domain: 'example.com',
          revenue: {
            currency: s.currency.toUpperCase(),
            amount: fromStripeAmount(s.amount_total, s.currency)
          },
          props: {
            plan: s.metadata?.plan || 'unknown',
            mode: s.mode  // payment | subscription
          }
        })
      });
    }
    res.json({ received: true });
  }
);
// On the success page, BEFORE redirecting to Stripe Checkout:
const customerIp = req.headers['cf-connecting-ip']
                || req.headers['x-forwarded-for']?.split(',')[0]
                || req.ip;

await stripe.checkout.sessions.create({
  mode: 'subscription',
  // ...
  metadata: { customer_ip: customerIp },                       // for checkout.session.completed
  payment_intent_data: { metadata: { customer_ip: customerIp } }, // for charge.refunded / dispute.*
  subscription_data:   { metadata: { customer_ip: customerIp } }  // for invoice.paid renewals
});

Why all three: Stripe doesn’t include the customer’s IP in any webhook payload. X-Forwarded-For must be a parseable IP — passing a country code or anything non-numeric makes Plausible’s bot filter drop the event silently. The session metadata is your only reliable transport, and it must be copied into payment_intent_data + subscription_data at session-create time so the IP is available on every downstream webhook.

Refunds, chargebacks, and renewals

Plausible CE has no delete or modify API. Negate revenue by firing a follow-up event with negative amount. Subscribe to five event types to cover the full lifecycle:

  • refund.createdRefund event with negative amount. Use this rather than charge.refunded: since Stripe API 2022-11-15, the Charge object no longer auto-expands its refunds list, so reading charge.refunds.data[0].reason in a charge.refunded handler returns undefined. The refund.* event family delivers the Refund object directly with reason populated. The webhook payload’s refund.amount is the per-refund delta — sum-safe across partial refunds.
  • charge.dispute.funds_withdrawnChargeback event with negative amount (separate goal — chargebacks have very different commercial implications than voluntary refunds).
  • charge.dispute.funds_reinstatedChargeback-Reversed event with positive amount, fired when you win a dispute. Without this, won disputes leave a permanent negative bias in your dashboard.
  • invoice.paid with billing_reason === 'subscription_cycle'Renewal event for recurring subscriptions (NOT checkout.session.completed, which only fires on initial purchase). Without this, your MRR looks like one-time payments only.
  • customer.subscription.deletedCancel event (no revenue field; useful for churn cohort SQL).
// On refund.created webhook (NOT charge.refunded)
const refund = event.data.object;
plausibleEvent({
  name: 'Refund',
  customerIp: refund.metadata?.customer_ip,  // propagated from payment_intent_data
  props: { reason: refund.reason || 'unspecified' },
  revenue: {
    currency: refund.currency.toUpperCase(),
    amount: -fromStripeAmount(refund.amount, refund.currency)
  }
});

Register Refund, Chargeback, Chargeback-Reversed, Renewal, and Cancel as separate goals. ClickHouse sum() aggregations treat the negatives correctly — but the dashboard’s “Total revenue” widget does not consistently subtract negatives across goal scopes. Trust the SQL; verify the dashboard.

Paddle and LemonSqueezy mapping

The same pattern works for Paddle (transaction.completed + transaction.refunded) and LemonSqueezy (order_created + order_refunded). Only the payload field names change — signature verification, idempotency, and the Plausible POST stay identical.

Querying raw events in ClickHouse (CE-only)

This is the part that the Cloud-only Funnels UI deliberately doesn’t expose. On self-hosted CE, you can read events_v2 and sessions_v2 directly — useful for verifying events arrived correctly, debugging dashboard mismatches, and building reports the UI doesn’t ship.

Connect to ClickHouse from the host running your Plausible compose stack:

docker compose exec plausible_events_db clickhouse-client

The two tables you’ll touch most:

  • events_v2 — one row per fired event. Key columns: name, timestamp, session_id, user_id, site_id, hostname, pathname, referrer, referrer_source, utm_source, utm_medium, utm_campaign, utm_term, utm_content, country_code, device, browser, os, revenue_source_amount, revenue_source_currency, revenue_reporting_amount, "meta.key", "meta.value" (parallel Array(String) columns for custom props). UTM and acquisition columns live here directly — no JOIN needed for revenue-by-source queries.
  • sessions_v2 — one row per session, used for session-level metrics (duration, entries, exits). Engine is VersionedCollapsingMergeTree, so any direct query against it must add FINAL or filter sign = 1 — otherwise collapsed rows double-count.
  • Use revenue_reporting_amount for funnel/totals queries (already converted to your reporting currency). revenue_source_amount + revenue_source_currency are the original payment values, useful only for per-currency breakdowns.

Inspect what’s actually stored for a given event name — the canonical “is my event arriving?” query:

SELECT
  timestamp,
  pathname,
  revenue_source_amount,
  revenue_source_currency,
  "meta.key",
  "meta.value"
FROM events_v2
WHERE site_id = <YOUR_SITE_ID>
  AND name = 'Purchase'
  AND timestamp >= now() - INTERVAL 1 HOUR
ORDER BY timestamp DESC
LIMIT 10;

If rows are present and the dashboard shows zero, the goal isn’t registered or the name doesn’t match. If revenue_source_amount is null but the event exists, the tracker variant doesn’t include revenue — check your script URL.

Funnels via ClickHouse SQL: the $19/month bypass

Plausible Cloud’s Funnels feature is a UI wrapper on top of ClickHouse aggregation queries against the same tables you just inspected. Self-hosted CE doesn’t expose the UI, but it doesn’t lock the data either — you can run the same queries directly. Trade-offs: you write SQL, you maintain queries when the schema evolves, you lose the dashboard polish.

Three-step funnel (within 24h)

Pricing page → Signup → Purchase, last 30 days, using maxIf aggregation:

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;
plausible 🙂
SELECT step1_pricing, step2_signup, step3_purchase FROM funnel_users_30d;
┌─step1_pricing─┬─step2_signup─┬─step3_purchase─┐
│        1247 │        312 │          89 │
└──────────────┴─────────────┴───────────────┘
1 row in set. Elapsed: 0.043 sec. Processed 184k rows.
plausible 🙂 _
Sample funnel output: 1247 pricing views, 312 signups, 89 purchases. The same numbers Cloud charges $19/mo to render.

Cross-day funnels and the privacy trade-off

user_id in Plausible is a salted hash, and the salt rotates every 24 hours. The same visitor on day 1 and day 2 gets two different user_id values — that’s the privacy design and it’s the reason Plausible needs no consent banner. Cross-day funnels via user_id don’t work.

Revenue by UTM source

UTM columns live directly on events_v2 — copied in at ingestion time from the request’s session context. No JOIN needed. (Earlier docs sometimes claim UTM is sessions-only; that hasn’t been true since the v2 schema migration.)

SELECT
  utm_source,
  count() AS purchases,
  sum(revenue_reporting_amount) AS revenue
FROM events_v2
WHERE site_id = ?
  AND name = 'Purchase'
  AND timestamp >= now() - INTERVAL 30 DAY
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.

When Plausible isn’t the right tool

The honest version. If any of the following describes your job-to-be-done, you’ll fight Plausible the whole way:

  • You need 50+ distinct event types. Plausible is built for ~20-30. Beyond that the dashboard becomes hard to read. Use PostHog or Matomo (see the Matomo ecommerce tracking guide for the equivalent revenue stack).
  • You need cross-day or cross-device funnels for anonymous users. The 24h salt rotation makes this fundamentally impossible without crossing a privacy line you probably don’t want to cross.
  • You need to delete or correct historical events. Plausible has no delete API. Refunds work via negative-revenue events, but a typo in an event name is permanent until you query around it in SQL.
  • You need Google Ads conversion tracking with Consent Mode v2. Plausible doesn’t ship a Google Ads integration; you’ll need server-side tagging via something like sGTM, which defeats the point.
  • You need a real-time event stream consumer. Plausible has no Webhook-out, Kafka, or SSE feed. ClickHouse is the only outbound interface, and it’s batch-friendly, not push.
  • Your team is non-technical. Self-hosted CE requires infrastructure ownership; Plausible Cloud Business at $19/mo is genuinely the right call if you don’t have a dev who’ll babysit Docker.

If those don’t apply, Plausible CE handles 80%+ of SaaS revenue tracking jobs with the patterns in this guide.

Four pitfalls that silently drop conversion data

Pitfall DevTools signal Dashboard signal Fix
1. Tracker variant mismatch 202 OK on /api/event Goal counts up, revenue stays $0 Add .revenue segment to script URL (alphabetical order)
2. Goal name case mismatch 202 OK on /api/event Goal stays at zero SQL: SELECT DISTINCT name FROM events_v2 → align goal name exactly
3. Reverse-proxy strips XFF No XFF header on /api/event request Unique-visitor count collapses to ~1 nginx: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
4. Ad-blocker strips endpoint Blocked request in DevTools (red) Traffic appears 20-30% lower than reality Reverse-proxy /js/script.js + /api/event through your apex domain
Common silent-drop modes. 202 Accepted doesn’t mean the event was kept — it means the request reached the endpoint.

The recurring theme: Plausible’s /api/event returns 202 Accepted for both accepted events and events the bot filter dropped. There’s no error to grep for. The diagnostic move is always: query events_v2 in ClickHouse to confirm rows exist with the exact name + props you expect.

Quick-pick cheatsheet

The most common events with their canonical payloads and admin-UI goal names:

Event name (case-sensitive)TriggerPropsRevenue
Signupform submit{plan, source}
PurchaseStripe webhook{plan, mode}{currency, amount}
Refundcharge.refunded webhook{plan, reason}{currency, -amount}
Renewalinvoice.paid webhook{plan, period}{currency, amount}
Plan-Upgradesubscription.updated{from, to}{currency, amount}
Leadcontact form submit{form}
File Downloadauto via .file-downloads.js{url}
Outbound Link: Clickauto via .outbound-links.js{url}
CTA-Clickdata-attr or JS{variant, page}

The auto-fired event names — File Download and Outbound Link: Click — include literal spaces and the colon. Register the goals exactly as written or auto-tracking events won’t match. A typo here is the most common reason “outbound clicks aren’t tracking.”

Need to verify your tracking is wired correctly across the whole site? The 30-minute tracking audit checklist covers the full QA sweep.

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 8.
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 30 maximum. If you need more dimensions, you’ve outgrown Plausible — consider PostHog or Matomo.
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 10. Cloud’s Funnels is a UI wrapper on those same queries.
Can I backfill historical events from Stripe into Plausible?
Not via the Events API — the ingestion server writes timestamp = now() and ignores any timestamp in the request body. The only way to backfill is direct INSERT into events_v2 against ClickHouse, bypassing the API. Match the column shape exactly (including session_id, user_id, the salt-of-day for the historical date) or the dashboard will treat the rows inconsistently. For most cases, accepting the lack of backfill is simpler.
Where are events stored in self-hosted Plausible?
The events_v2 table in the plausible_events_db ClickHouse instance. Sessions live separately in sessions_v2. Custom props are stored as parallel arrays on events_v2 in "meta.key" and "meta.value" columns.
Why aren’t my custom events showing in the dashboard?
Three causes, ordered by frequency: (1) you haven’t registered the event as a Goal in Site Settings; (2) the goal name is case-mismatched against what’s actually stored; (3) the tracker script variant doesn’t include the segment your event needs (revenue, tagged-events). Run the diagnostic SQL in section 9 to see what’s actually in ClickHouse.
Does revenue tracking break Plausible’s “no consent banner” claim?
Pure currency + amount fields do not constitute personal data and don’t trigger consent requirements. But if you add custom props like email_domain, customer_id, or anything user-identifiable, you cross into PII territory and lose the consent-free posture. Keep revenue payloads to anonymous fields only (plan, currency, amount, mode) to stay clean.
How do I debug a “dropped” event?
The Events API returns 202 Accepted for both kept and dropped events — status code alone tells you nothing. Use the two debug surfaces Plausible exposes: the x-plausible-dropped: 1 response header (set by the bot filter on the same 202 when the event was actually dropped), and the X-Debug-Request: true request header you set, which makes Plausible return 200 with the resolved IP in the response body so you can verify X-Forwarded-For is parseable. As a fallback, query events_v2 directly — if the row isn’t there, the bot filter dropped it. Most common causes: missing or empty User-Agent, malformed X-Forwarded-For (must be a parseable IP), or data-domain mismatch against the site registered in admin.
Is querying ClickHouse directly compliant with Plausible’s AGPL license?
Yes. The AGPL applies to modifications of Plausible’s source code that are exposed over a network. Reading from events_v2 from your own SQL queries doesn’t fall under that — it’s the same as querying any database you own.


Found this useful?

Try the Stack Picker to get a personal recommendation, or browse the install recipe library.