# Account settings (/docs/account-settings) Access your account settings by clicking on "Account" or going to [app.rybbit.io/settings/account](https://app.rybbit.io/settings/account). account settings ## Account [#account] ### Name [#name] Update your display name shown across the platform. This name is visible to other members in your organization. ### Email [#email] Update your email address for account notifications and login. This is the primary email associated with your account. ### Weekly Email Reports (Cloud) [#weekly-email-reports-cloud] Enable or disable automatic weekly email reports. When enabled, you'll receive a summary of your analytics performance delivered to your inbox each week. ### Sign Out [#sign-out] Sign out of your current session. ## Security [#security] ### Change Password [#change-password] Update your password to keep your account secure. ### Delete Account [#delete-account] Permanently delete your account and all associated data. This action cannot be undone. If you have an active subscription, you must cancel it before deleting your account. ## API Keys [#api-keys] Generate API keys to access Rybbit's analytics endpoints from your applications. ### Creating an API Key [#creating-an-api-key] 1. Enter a name for your API key (e.g., "Production App", "Development") 2. Optionally enable **Restrict permissions** to limit the key to specific resources. Leave it off for a full-access key that can do everything you can. 3. Click "Create" 4. Copy and save the generated key immediately On Rybbit Cloud, API keys require a Standard or Pro plan. On a free plan you'll see an upgrade prompt in place of the creation form. API keys are only shown once when created. Store your key securely as you won't be able to view it again. ### Managing API Keys [#managing-api-keys] Your existing API keys are displayed in a table showing: * **Name** - The name you assigned to the key * **Key** - A preview of the key (first few characters) * **Permissions** - Whether the key has full access or is restricted to specific scopes * **Created** - When the key was created * **Last used** - When the key last made a request You can delete API keys that are no longer needed. Deleting a key immediately revokes access for any applications using it. ### Using API Keys [#using-api-keys] Include your API key in requests to Rybbit's API endpoints. See the [API documentation](/docs/api) for available endpoints and usage examples. # Autocapture (/docs/autocapture) Rybbit can automatically capture common user interactions without requiring any code changes. These features can be enabled in your [Site Settings](/docs/site-settings) under the "Auto Capture" section. ## Available Autocapture Features [#available-autocapture-features] | Feature | Description | Event Type | | ----------------- | ---------------------------------------- | ----------------------------- | | Outbound Links | Track clicks on external links | `outbound` | | Error Tracking | Capture JavaScript errors and exceptions | `error` | | Button Clicks | Track clicks on buttons | `button_click` | | Copy Events | Track when users copy text | `copy` | | Form Interactions | Track form submissions and input changes | `form_submit`, `input_change` | ## Outbound Links [#outbound-links] When enabled, Rybbit automatically tracks clicks on links that navigate to external domains. **Tracked properties:** * `url` - The destination URL * `text` - The link text * `target` - The link target attribute (e.g., `_blank`, `_self`) ## Error Tracking [#error-tracking] Captures uncaught JavaScript errors and unhandled promise rejections from your site. Only errors originating from your domain are tracked (third-party script errors are filtered out). **Tracked properties:** * `message` - The error message * `stack` - Stack trace (truncated to 2000 characters) * `fileName` - Source file where the error occurred * `lineNumber` - Line number * `columnNumber` - Column number ## Button Clicks [#button-clicks] Automatically tracks clicks on: * ` ``` This tracks: `{ text: "Buy Now", category: "pricing", variant: "blue" }` ### Skipping Autocapture for Specific Buttons [#skipping-autocapture-for-specific-buttons] If you want to track a button with a custom event instead of the automatic `button_click`, add the `data-rybbit-event` attribute: ```html ``` See [Track Events](/docs/track-events) for more details on custom event tracking. ## Copy Events [#copy-events] Tracks when users copy text from your site using Ctrl+C, Cmd+C, or the context menu. **Tracked properties:** * `text` - The copied text (truncated to 500 characters) * `textLength` - Original length of the copied text (only included if text was truncated) * `sourceElement` - The HTML tag where the text was copied from (e.g., `p`, `div`, `span`) ## Form Interactions [#form-interactions] Tracks two types of form events: ### Form Submissions (`form_submit`) [#form-submissions-form_submit] Triggered when a form is submitted. **Tracked properties:** * `formId` - The form's `id` attribute * `formName` - The form's `name` attribute * `formAction` - The form's `action` URL * `method` - HTTP method (GET, POST, etc.) * `fieldCount` - Number of form fields * `ariaLabel` - The form's `aria-label` attribute (if present) You can add custom properties using `data-rybbit-prop-*` on the form element: ```html
...
``` ### Input Changes (`input_change`) [#input-changes-input_change] Triggered when a user changes the value of an input, select, or textarea field. Disabled inputs are automatically excluded from tracking. **Tracked properties:** * `element` - The element type (`input`, `select`, or `textarea`) * `inputType` - For inputs: the type attribute (e.g., `text`, `email`, `checkbox`) * `inputName` - The field's `name`, `id`, `aria-label`, or `placeholder` attribute (first available) * `formId` - The parent form's `id` (if within a form) * `formName` - The parent form's `name` (if within a form) You can add custom properties using `data-rybbit-prop-*` on the input element: ```html ``` **Privacy note:** Hidden inputs, password fields, and disabled inputs are automatically excluded from tracking. ## Enabling Autocapture [#enabling-autocapture] 1. Go to your site's **Settings** 2. Navigate to the **Auto Capture** section 3. Toggle on the features you want to enable Changes take effect immediately for new visitors. Existing sessions may need to refresh to pick up the new configuration. *** **Related Documentation:** * [Track Events](/docs/track-events) - Manual event tracking * [Site Settings](/docs/site-settings) - Configure your site * [Behavior Analytics - Events](/docs/behavior-analytics/events) - View captured events # Billing (/docs/billing-settings) Access billing settings by hovering over the sidebar and clicking "Billing". Subscriptions are scoped to a single organization. If you have multiple organizations, each one requires its own subscription. Switching organizations will show that organization's subscription status. # Bot detection (/docs/bot-detection) Rybbit can filter bot traffic before it reaches your normal analytics. When **Block Bot Traffic** is enabled for a site, each incoming tracking request is checked by several detection layers. If any layer identifies the request as bot traffic, the request is excluded from the normal analytics tables and stored separately for bot traffic inspection. ## Enabling Bot Detection [#enabling-bot-detection] Bot blocking is configured per site: 1. Open your site in the Rybbit dashboard 2. Go to **Site Settings** 3. Enable **Block Bot Traffic** When this setting is off, Rybbit does not block requests based on bot detection. ## What Happens to Detected Bots [#what-happens-to-detected-bots] Detected bot requests are not added to your normal analytics data. This keeps dashboard totals, reports, journeys, funnels, session lists, and usage metrics focused on human traffic. Detected bot visits also do not count toward billable analytics usage. If bot blocking filters a request, you are not charged for that bot visit. Rybbit still stores a compact bot event record so you can inspect what was filtered. Bot event records include route, device, location, ASN, and which detection layers matched. ## Detection Layers [#detection-layers] Rybbit runs all detection layers before making a final decision. A request can match multiple layers, and the bot event records every layer that matched. ### User-Agent Patterns [#user-agent-patterns] The `ua_pattern` layer checks the request user-agent against known bot, crawler, AI agent, SEO tool, monitoring, social preview, framework, and headless browser patterns. Examples include: * Search engine crawlers * Headless browser user-agents * AI crawler and agent user-agents * SEO and monitoring tools * Script or framework HTTP clients ### Header Heuristics [#header-heuristics] The `header_heuristics` layer scores request headers for browser consistency. It looks for signals such as: * Missing browser headers * Suspicious fetch metadata * Inconsistent browser claims * Headless or automation-looking headers * Stale or unusual Chrome versions * Script/framework-style requests that do not look like normal browser traffic This layer is useful because many bots use a browser-like user-agent but do not send the full set of headers a real browser normally sends. ### Client Signals [#client-signals] The `client_signals` layer uses lightweight browser-side signals collected by the tracking script. Signals include: * Automation APIs * Zero or impossible window dimensions * Default automation viewport sizes such as `800x600` and `1024x768` * Suspicious outer window dimensions * Missing browser APIs * Missing Chrome globals * SwiftShader renderer signals * Empty plugin lists Rybbit combines these into a weighted score. Strong signals can identify bot traffic on their own, while weaker signals contribute supporting evidence. ### ASN and Network Signals [#asn-and-network-signals] The `bot_asn` layer uses ASN metadata from the resolved IP address. There are two kinds of ASN matches: * **Curated bot provider ASNs**: Known AI, scanner, and internet measurement providers can trigger bot detection directly. * **Generic hosting/datacenter ASNs**: Hosting ASNs are treated as supporting evidence. They are recorded when another bot layer also matches, but generic hosting ASN alone is not enough to block a request. This avoids filtering every legitimate visitor who happens to browse through a cloud, CDN, VPN, corporate gateway, or first-party proxy, while still preserving ASN context when other bot evidence exists. ### Rate and Anomaly Detection [#rate-and-anomaly-detection] The `rate_anomaly` layer watches for bursty or crawl-shaped behavior. It tracks short rolling windows for patterns such as: * Too many events from the same IP and user-agent * Too many events from the same IP * Too many distinct paths visited quickly * Too many different user-agents from one IP * Too many hostnames from one IP * High site-wide volume from one user-agent * Large volumes of requests missing client-side bot scores This layer is designed to catch fast crawlers, floods, and replayed tracking requests that may not have obvious user-agent or browser fingerprint signals. ## Bot Identity [#bot-identity] Every detected bot carries the layers that caught it. Bots matched by one of Rybbit's curated user-agent patterns carry three more fields, which is what the Bots dashboard groups by: | Field | Example | Meaning | | -------------- | ------------- | -------------------------------------------- | | `bot_name` | `GPTBot` | The name the bot publishes in its user agent | | `bot_operator` | `OpenAI` | Who runs it | | `bot_purpose` | `ai_training` | What the fetch is for | A bot matched only by a broad pattern (`crawl`, `spider`) is still filtered, but has no published identity — those rows read as **Unnamed**, which is more useful than a guess. ### Purposes [#purposes] `bot_purpose` answers a different question from `bot_category`. Category groups bots into families; purpose says what the operator does with the page, which is the distinction that matters for AI traffic: | Purpose | What it means | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ai_training` | Collecting pages to train a model. Sends no readers back. Examples: GPTBot, ClaudeBot, CCBot, Google-Extended, Bytespider | | `ai_search` | Indexing pages so an assistant can cite them. Can send readers back. Examples: OAI-SearchBot, Claude-SearchBot, PerplexityBot | | `ai_agent` | A person asked an assistant to open the page, and it fetched it there and then. Examples: ChatGPT-User, Claude-User, Perplexity-User, Google-Agent, Claude Code | | `search` | Classic search engine indexing | | `social_preview` | Link unfurling | | `seo` | Backlink, rank, and site-audit crawlers | | `monitoring` | Uptime, synthetic, and performance checks | | `security` | Scanners | | `scripted` | HTTP libraries and CLI clients | | `headless` | Browser automation | Splitting the AI family in three is the point: a training crawler reading 4,000 pages and a person's assistant opening one page are both `category: ai`, but they mean opposite things for your site. These fields were added in a later release. Bot events recorded before then have empty identity fields and appear as **Unnamed** / **Unclassified**; nothing is backfilled. ## How Decisions Are Made [#how-decisions-are-made] Rybbit does not stop at the first matching layer. It runs every layer, records all matches, and then makes one final decision. A request is marked as bot traffic when at least one blocking layer matches. The resulting bot event includes boolean fields for each layer: * User-agent pattern * Header heuristics * Client signals * Bot ASN * Rate anomaly Because multiple layers can match the same request, per-layer bot counts can add up to more than the total number of bot requests. ## Server-Side Tracking [#server-side-tracking] Requests sent to `/api/track` with `Authorization: Bearer ` and a valid API key are treated as trusted server-side ingestion. Four of the five layers are skipped: the headers, the client signals, the network address, and the request rate all belong to your server, not to the visitor it is reporting, so scoring them would convict your own backend. The user-agent layer still runs, on the `user_agent` you send. Nothing changes for ordinary backend events — a request reporting a real browser's user agent is tracked normally. ### Reporting crawlers that never run JavaScript [#reporting-crawlers-that-never-run-javascript] Rybbit's tracker is JavaScript, so a crawler that fetches your HTML and executes nothing never reaches Rybbit at all. Most AI training crawlers work exactly that way, which is why the Bots dashboard carries a note about its own coverage. If you want to see them, report them from the edge — middleware, a worker, or a reverse proxy — forwarding the real request's `user_agent`: ```json { "site_id": "1", "hostname": "example.com", "pathname": "/pricing", "user_agent": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.2; +https://openai.com/gptbot" } ``` A forwarded crawler user agent is classified and lands in your bot events with full identity, rather than in your analytics as a phantom visitor. Do not expose API keys in browser JavaScript. API keys are only for server-side requests. ## Proxies and CDN Setups [#proxies-and-cdn-setups] If you proxy Rybbit through Cloudflare Workers, AWS CloudFront, Nginx, Caddy, or another reverse proxy, forward the original visitor IP: ```txt X-Forwarded-For: X-Real-IP: ``` Also preserve the original `User-Agent`, `Referer`, and `Accept-Language` headers where possible. If the proxy IP is sent instead of the visitor IP, traffic may be geolocated to the proxy location and may inherit the proxy provider's ASN. See the [proxy troubleshooting guide](/docs/proxy-guide/troubleshooting) for examples. ## What Bot Detection Does Not Guarantee [#what-bot-detection-does-not-guarantee] Bot detection improves analytics quality, but no bot filter is perfect. Some sophisticated bots can look like normal browsers. Some legitimate users may browse through unusual network paths or constrained browser environments. Rybbit uses multiple layers to reduce both misses and false positives, but you should still interpret bot counts as an operational signal rather than an exact measurement of all automation on your site. # Comparison (/docs/comparison) | Feature | Rybbit | GA4 | Plausible | Cloudflare | | --------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------------------------------------ | | **Open Source** | | | | | | **Self-Hosting** | | | \* | | | **Cookieless & Privacy friendly** | | | | | | **Advanced Maps** | | | | | | **Advanced Filters** | | Limited | Limited | | | **Web Vitals** | Paid | | | | | **Session Details** | | | | | | **User Profiles** | | | | | | **Session Replays** | | | | | | **Funnels** | | | Paid | | | **User Journeys** | | | | | | **Retention Analysis** | | | | | | **Goals & Events** | | | | | | **Real-time Dashboard** | | | | | | **Custom Events (JSON)** | | | Limited | | | **Error Tracking** | | | | | | **Public Dashboards** | | | | | | **Organizations** | | | | | | **Frog 🐸** | | | | | \* Plausible's Community Edition has very limited features compared to their cloud version # Data Import (/docs/data-import) The data import feature allows you to migrate your historical analytics data from other platforms to Rybbit. This preserves your analytics history when switching from competitors, giving you a complete view of your data without losing valuable insights. ## Supported Platforms [#supported-platforms] Currently, Rybbit supports importing data from: * Umami * Simple Analytics * Plausible Additional platforms may be added in the future based on user demand. Umami and Simple Analytics are imported from a CSV export, while Plausible is imported from its ZIP export. The Import dialog automatically expects the correct file type for the platform you select. ## Plan Requirements [#plan-requirements] ### Cloud Version [#cloud-version] | Plan | Access | Historical Window | Monthly Event Limit | | -------- | --------------- | ------------------- | ------------------- | | Free | ❌ Not available | - | - | | Standard | ✅ Available | 36 months (3 years) | Plan limit applies | | Pro | ✅ Available | 60 months (5 years) | Plan limit applies | ### Self-Hosted Version [#self-hosted-version] Self-hosted installations have **unlimited** access to data imports with no restrictions on: * Historical data window (import data from any date) * Event quotas (no monthly limits) * Concurrent imports (no limit on active imports) ## Import Limits [#import-limits] ### Historical Data Windows [#historical-data-windows] The historical data window determines how far back you can import data: * **Standard plans**: Import data from the past 36 months (3 years) * **Pro plans**: Import data from the past 60 months (5 years) * **Self-hosted**: Unlimited - import data from any date Events outside your plan's historical window will be automatically skipped during import. ### Event Quotas [#event-quotas] Imported events count toward your monthly event limit: * If you're close to your monthly limit, imports may be partially skipped * Skipped events are counted and displayed in the import history * Consider upgrading your plan if you need to import large amounts of data ### Concurrent Imports [#concurrent-imports] For cloud users: * **Maximum 1 active import per organization** * New imports are blocked until the current one completes * Maximum 2-hour timeout per import Self-hosted users have no concurrent import restrictions. ## How to Import Data [#how-to-import-data] ### Export Data from Source Platform [#export-data-from-source-platform] **Umami:** 1. Navigate to Settings → Data 2. Export your data as CSV 3. Ensure the export includes all required fields **Simple Analytics:** 1. Go to your site settings 2. Navigate to Export section 3. Download your analytics data as CSV **Plausible:** 1. Open your site's dashboard 2. Use the CSV export option to download your data 3. Keep the exported **ZIP** archive as-is — do not unzip it ### Navigate to Import Settings [#navigate-to-import-settings] 1. Open your Rybbit dashboard 2. Navigate to Site Settings 3. Click on the **Import** tab ### Select Platform and Upload File [#select-platform-and-upload-file] 1. Select your source platform from the dropdown 2. Click "Choose File" and select your exported file (a **CSV** for Umami and Simple Analytics, or a **ZIP** for Plausible) 3. Click the **Import** button For very large imports (multiple GB), consider splitting your CSV into smaller files and importing them sequentially. ### Monitor Import Progress [#monitor-import-progress] Your import will begin processing in the background. You can: * View the import status in the "Import History" section * Monitor the number of imported, skipped, and invalid events * Continue using Rybbit while the import runs Large imports may take several minutes to complete. The page will automatically refresh to show updated progress. ## Import History [#import-history] The Import History section displays all your past and current imports. ### Import Metrics [#import-metrics] Each import shows three key metrics: **Imported Events** * Number of events successfully imported into Rybbit * These events are now visible in your analytics dashboard **Skipped Events** * Events that were excluded during import * Common reasons: * Outside your plan's historical data window * Would exceed your monthly event quota **Invalid Events** * Events that failed validation during import * Common reasons: * Malformed CSV data * Missing required fields * Invalid date/time formats * Data type mismatches If you see a high number of invalid events, verify your CSV export matches the expected format for your platform. ### Managing Imports [#managing-imports] **Deleting Completed Imports** * Click the trash icon next to any completed import * Confirm the deletion in the dialog * This will permanently remove the import record **and all associated events** from your analytics * Active imports cannot be deleted Deleting an import removes all events that were imported. This action cannot be undone. Make sure you have a backup of your original CSV file. ## Troubleshooting [#troubleshooting] ### Events Skipped Due to Quota Limits [#events-skipped-due-to-quota-limits] **Cause:** Importing the events would exceed your monthly event limit. **Solution:** * Wait until the next billing cycle when your quota resets * Upgrade to a plan with a higher event limit * Import smaller date ranges to stay within quota ### Events Skipped Due to Date Range [#events-skipped-due-to-date-range] **Cause:** Events are older than your plan's historical data window. **Solution:** * Upgrade to Pro plan for 60-month history (vs 24-month on Standard) * For self-hosted, no action needed - all dates are allowed * Filter your CSV export to only include recent data ### High Invalid Event Count [#high-invalid-event-count] **Cause:** CSV format doesn't match the expected structure for your platform. **Solution:** 1. Verify you selected the correct platform 2. Check that your CSV export includes all required fields 3. Look for special characters or malformed data in the CSV 4. Try importing a small sample file (first 100 rows) to test ### Import Appears Stuck [#import-appears-stuck] **Cause:** Very large files can take time to process, or a timeout occurred. **Solution:** * Wait up to 2 hours for the import to complete * If still stuck after 2 hours, the import will be automatically marked as complete * Check the imported event count to see if any data was processed * Contact support if no events were imported ## Best Practices [#best-practices] ### Before You Import [#before-you-import] 1. **Export a complete backup** from your current analytics platform 2. **Test with a small sample** (first 1,000 rows) before importing your full dataset 3. **Check your event quota** to ensure you have sufficient capacity 4. **Verify date ranges** match your plan's historical window 5. **Review CSV format** matches the required structure for your platform ### During Import [#during-import] 1. **Don't close the browser tab** 2. **Monitor the progress** in the Import History section 3. **Check for skipped/invalid events** early to catch format issues 4. **Wait for completion** before starting another import (cloud users) ### After Import [#after-import] 1. **Verify your data** appears correctly in analytics dashboards 2. **Check date ranges** to ensure historical data is visible 3. **Compare totals** with your source platform to validate accuracy 4. **Keep the original CSV** as a backup before deleting the import # Definitions (/docs/definitions) ## Aggregate Metrics [#aggregate-metrics] ### Unique Users [#unique-users] The total number of distinct individuals who visited your website, identified by a unique user ID. Each user is counted only once regardless of how many times they visit. ### Sessions [#sessions] A series of user interactions with your website within a given time frame. A session ends after 30 minutes of inactivity from the same user ID. Multiple pageviews can occur within one session. ### Pageviews [#pageviews] The total number of times visitors have viewed pages on your website. Each time a page is loaded or reloaded counts as a pageview. ### Pages Per Session [#pages-per-session] The average number of pages viewed during a session. Calculated by dividing the total number of pageviews by the total number of sessions. ### Bounce Rate [#bounce-rate] The percentage of sessions where a user leaves your site after viewing only one page without any interaction. A high bounce rate might indicate that landing pages aren't relevant to visitors. ### Session Duration [#session-duration] The average length of time users spend on your site during a session. Longer sessions typically indicate more engaged visitors. ## Individual Datapoints [#individual-datapoints] ### User ID [#user-id] A unique identifier generated from a hash of the visitor's IP address and browser user-agent. This anonymized ID helps track unique visitors without storing personally identifiable information. ### Hostname [#hostname] The domain name of the website being visited (e.g., example.com). ### Pathname [#pathname] The path portion of the URL that follows the hostname (e.g., /blog/article). ### Entry Page [#entry-page] The first page viewed by a user during a session. Identifies which pages commonly serve as entry points to your site. ### Exit Page [#exit-page] The last page viewed by a user before ending their session. Helps identify where users tend to leave your site. ### Querystring [#querystring] The portion of a URL that follows a question mark, containing parameters (e.g., ?utm\_source=newsletter). ### Page Title [#page-title] The title of the webpage as defined in the HTML `` tag. ### Referrer [#referrer] The URL that a user was on before coming to your site. Shows how users are discovering your content. ### UTM Source [#utm-source] Identifies which website, search engine, or other source is sending traffic to your site (e.g., google, newsletter, twitter). ### UTM Medium [#utm-medium] Identifies the marketing medium (e.g., cpc, email, social). ### UTM Term [#utm-term] Identifies paid search keywords. Used primarily for paid search campaigns. ### UTM Campaign [#utm-campaign] Identifies the specific campaign or promotion that brought users to your site. ### UTM Content [#utm-content] Identifies what specifically was clicked to bring the visitor to your site (e.g., banner ad, text link). ### Channel [#channel] A categorization of traffic sources into standard groups such as direct, search, social, email, or referral. ### Browser [#browser] The web browser used by the visitor (e.g., Chrome, Firefox, Safari). ### Browser Version [#browser-version] The specific version of the browser being used. ### Operating System [#operating-system] The operating system used by the visitor (e.g., Windows, macOS, iOS, Android). ### Operating System Version [#operating-system-version] The specific version of the operating system being used. ### Language [#language] The preferred language setting of the visitor's browser. ### Country [#country] The country where the user is located, based on IP geolocation. ### Region [#region] The state or region where the user is located, based on IP geolocation. ### City [#city] The city where the user is located, based on IP geolocation. ### Latitude [#latitude] The geographic latitude coordinate of the user's approximate location. ### Longitude [#longitude] The geographic longitude coordinate of the user's approximate location. ### Screen Width [#screen-width] The width of the visitor's screen or device viewport in pixels. ### Screen Height [#screen-height] The height of the visitor's screen or device viewport in pixels. ### Device Type [#device-type] The type of device being used (e.g., desktop, tablet, mobile). ### Event Name [#event-name] The name of a custom event triggered by user interaction (e.g., "button\_click", "form\_submit"). ### Event Properties [#event-properties] Additional contextual data associated with custom events, stored as key-value pairs. Used to provide more detail about specific interactions. # Embeds (/docs/embed-widget) Rybbit supports two iframe embed options: * [Widget Embeds](/docs/embeds/widget) show a compact live visitor widget on any web page. * [Dashboard Embed](/docs/embeds/dashboard) shows your analytics dashboard in an iframe. Both embeds are self-contained iframes. There is no script tag, no JavaScript on your site, and no dependency on the Rybbit tracking script. ## Which Embed Should I Use? [#which-embed-should-i-use] Use [Widget Embeds](/docs/embeds/widget) when you want a small live visitor count, chart, or top-countries summary. Widget Embeds do not make your dashboard public. Use [Dashboard Embed](/docs/embeds/dashboard) when you want to publish the full read-only analytics dashboard. Dashboard Embed uses a generated private link, so Public Analytics is not required. # Experiments (/docs/experiments) Experiments are A/B tests built on top of Rybbit. Each experiment splits your visitors across the variants of a [multivariate feature flag](/docs/feature-flags), then measures how often each variant completes a [conversion goal](/docs/goals). Rybbit calculates the conversion rate for every variant, the **lift** relative to your control, and how confident you can be that the difference is real. <Callout type="info"> An experiment ties together two features you may already use: a **multivariate feature flag** decides which variant each visitor sees, and a **goal** defines the conversion you are optimizing for. If you understand those two pieces, you understand experiments. </Callout> ## How Experiments Work [#how-experiments-work] An experiment has three moving parts: | Part | What it does | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Assignment** | A multivariate feature flag assigns each visitor to one stable variant (for example `control` or `variant_a`) based on its rollout percentages. | | **Exposure** | When your code reads the flag with `rybbit.flag()`, Rybbit records an exposure for that variant. The number of distinct sessions exposed to a variant is the denominator for its conversion rate. | | **Conversion** | The experiment's primary goal — a page visit or a custom event — counts as a conversion for whichever variant the visitor was assigned. | Because assignment is sticky for a given visitor, a person sees the same variant on repeat visits, which keeps your results consistent. ## Creating an Experiment [#creating-an-experiment] Open your site in the Rybbit dashboard, go to **Experiments**, and click **New experiment**. A guided wizard walks you through each step. <Steps> <Step> ### Basics [#basics] Give the experiment a **name** and, optionally, a **hypothesis** and **description**. The hypothesis is internal context for your team — something like *"Changing the CTA copy will increase signups."* It does not affect assignment or tracking. New experiments are created as **drafts** so you can add the implementation code before any traffic is split. </Step> <Step> ### Assignment [#assignment] Choose how visitors are split into variants: * **Create a new flag** — Define a new multivariate flag with its own variants and rollout split. Each variant needs a unique key, and the rollout percentages must add up to **100%**. * **Use an existing flag** — Pick a multivariate flag that is not already attached to another experiment. <Callout type="info"> Each experiment uses its own multivariate flag. The flag's variant keys (such as `control` and `variant_a`) are what you branch on in code. </Callout> </Step> <Step> ### Goal [#goal] Pick the conversion you want to compare variants against: * **Create a new goal** — Define a **page goal** (a URL path such as `/signup/complete`) or an **event goal** (a custom event such as `signup_completed`). The form provides autocomplete suggestions based on paths and events already seen on your site. * **Use an existing goal** — Connect a goal you already track. * **Skip for now** — Create the experiment without a goal and add one later. Conversion results require a goal. </Step> <Step> ### Review and Implement [#review-and-implement] Review the assignment flag and goal, then create the experiment. Rybbit creates any new flag or goal you configured, connects them, and shows you the **implementation code** for the flag. The experiment stays in **draft** until you start it from the experiment list. </Step> </Steps> ## Implementing in Code [#implementing-in-code] Read the experiment's flag in your app and branch your UI or behavior on the returned variant. Reading the flag also records the exposure that powers your results, so make sure it runs wherever the tested experience is shown. <Tabs items="['JavaScript', 'TypeScript']"> <Tab value="JavaScript"> ```javascript window.rybbit.onReady((rybbit) => { const variant = rybbit.flag("checkout_cta", "control"); if (variant === "variant_a") { // Render the variant experience. } else { // Render the control experience. } }); ``` </Tab> <Tab value="TypeScript"> ```typescript type ExperimentVariant = "control" | "variant_a"; window.rybbit.onReady((rybbit) => { const variant = rybbit.flag("checkout_cta", "control") as ExperimentVariant; switch (variant) { case "variant_a": // Render the variant experience. break; default: // Render the control experience. break; } }); ``` </Tab> </Tabs> If your primary goal is an **event goal**, fire that event when the conversion action happens: ```javascript window.rybbit.onReady((rybbit) => { rybbit.event("signup_completed"); }); ``` **Page goals** need no extra code — Rybbit counts sessions that reach the goal's path automatically. <Callout type="info"> Use a stable variant for the `rybbit.flag()` fallback (usually `"control"`) so visitors still get a sensible experience before the flag resolves or if the experiment is paused. </Callout> ## Reading Results [#reading-results] Each experiment shows a results panel comparing every variant. Results respect the global date range and filters applied on the page. | Metric | Description | | ------------------- | --------------------------------------------------------------------------------------------------------------------- | | **Variant** | The variant key, with the **Control** marked as the baseline. | | **Sessions** | Sessions exposed to that variant, alongside the number that converted. | | **Conversion rate** | Conversions divided by the number of exposed sessions (the **Sessions** figure) for that variant. | | **Lift** | The percentage change in conversion rate relative to the control. Positive lift is shown in green, negative in red. | | **Confidence** | How confident Rybbit is that the variant's difference from the control is real, based on a two-proportion comparison. | Rybbit summarizes the state of each experiment at a glance: * **Gathering data** — Not enough exposures or conversions yet to draw a conclusion. * **Leading** — A variant is outperforming the control with at least **95% confidence**. * **Winner** — The variant recorded as the winner once the experiment is decided. <Callout type="warn"> Avoid stopping an experiment the moment a variant looks ahead. Let it run until it reaches 95% confidence with a meaningful number of conversions, otherwise early noise can masquerade as a real difference. </Callout> ## Experiment Lifecycle [#experiment-lifecycle] Experiments move through four statuses, which you control from the experiment list: | Status | Meaning | | ------------- | --------------------------------------------------------------------------------------- | | **Draft** | Created but not yet collecting results. Add your implementation code here. | | **Running** | Actively splitting traffic and recording exposures and conversions. | | **Paused** | Temporarily stopped. Visitors keep their existing assignments. | | **Completed** | Finished. Use this once you have a result and have shipped (or rolled back) the change. | Use **Start**, **Pause**, and **Complete** on each experiment to move between states. ## Best Practices [#best-practices] * Change one thing at a time so you can attribute any lift to a single difference. * Make sure your variant rollout percentages add up to 100% unless you intentionally want unassigned traffic. * Read the flag wherever the tested experience renders so every exposed visitor is counted. * Pick a primary goal that directly reflects the outcome you care about, not a proxy metric. * Wait for 95% confidence and a healthy sample size before declaring a winner. * After completing an experiment, ship the winning variant and remove the flag branch to keep your code clean. *** **Related Documentation:** * [Feature Flags](/docs/feature-flags) — The assignment mechanism behind experiments * [Goals](/docs/goals) — Define the conversion an experiment measures * [Track Events](/docs/track-events) — Fire custom events for event goals * [Tracking Script](/docs/script) — Load the script and use `onReady()` # Feature Flags (/docs/feature-flags) Feature flags let you change product behavior without deploying new code. In Rybbit, a flag can be evaluated in the browser, on your server, or in both places. Flag assignments are also attached to analytics events so you can filter and compare behavior by flag value. <Callout type="info"> Feature flags are different from [tags](/docs/tagging). Tags label traffic that you already routed yourself. Feature flags decide which value a visitor receives at runtime. </Callout> ## Create a Flag [#create-a-flag] 1. Open your site in the Rybbit dashboard. 2. Go to **Feature Flags**. 3. Click **New flag**. 4. Choose a stable **key**, such as `new_checkout` or `pricing_banner`. 5. Select the flag type, runtime, and targeting conditions. 6. Enable the flag when you are ready for it to evaluate as active. The **key** is the public identifier used in code. Treat it as the flag name and avoid changing it after the flag is in use. ## Flag Types [#flag-types] | Type | Returns | Best for | | --------------------- | ----------------------------------------------- | ------------------------------------------------------------------------- | | **Boolean** | `true` or `false` | Releasing a feature on/off, gradual rollouts, allowlists | | **Multiple variants** | A variant key like `"control"` or `"variant_a"` | Comparing more than one experience or configuration | | **Remote config** | `true` plus a payload from `flagPayload()` | Shipping structured config such as copy, colors, limits, or JSON settings | ### Boolean Flags [#boolean-flags] Boolean flags support a rollout percentage for each condition set. If the visitor matches the condition set and falls inside the rollout bucket, `window.rybbit.flag()` returns `true`. ```javascript window.rybbit.onReady((rybbit) => { const enabled = rybbit.flag("new_checkout", false); if (enabled) { showNewCheckout(); } }); ``` ### Multiple Variants [#multiple-variants] Multiple variant flags return the selected variant key. Each variant has its own rollout percentage and optional payload. ```javascript window.rybbit.onReady((rybbit) => { const variant = rybbit.flag("homepage_cta", "control"); if (variant === "variant_a") { renderAlternateCTA(); } }); ``` Variant rollout percentages are evaluated in order. If the visitor falls outside the total allocated percentage, the flag returns `false`. ### Remote Config [#remote-config] Remote config flags are useful when you want Rybbit to choose a JSON payload. For remote config, `flag()` returns whether a config matched, and `flagPayload()` returns the actual config. ```javascript window.rybbit.onReady((rybbit) => { const config = rybbit.flagPayload("checkout_config", { buttonText: "Continue", theme: "default", }); renderCheckout(config); }); ``` Remote config can still use targeting. This lets you serve one config to beta users, another config to a country, and a default config to everyone else. ## Runtime [#runtime] Each flag has a runtime: | Runtime | Evaluated by | Use when | | ---------- | ---------------------------- | -------------------------------------------------------------- | | **Client** | The browser tracking script | UI changes, frontend copy, client-only experiments | | **Server** | Your backend through the API | Backend behavior, pricing logic, data access, anything private | | **Both** | Browser and server | Both sides need the same decision | <Callout type="warn"> Do not put secrets in client or both-runtime flag payloads. Browser-evaluated flags and payloads are visible to the user. </Callout> ## Targeting and Condition Sets [#targeting-and-condition-sets] Flags use ordered condition sets. Each set contains targeting rules plus the rollout, variants, or payload for users who match that set. Evaluation works like this: 1. Rybbit checks condition sets from top to bottom. 2. The first condition set whose rules all match is selected. 3. Rybbit applies the rollout, variant allocation, or remote config payload from that selected set. 4. Later condition sets are not evaluated. This means condition set order matters. Put narrow segments first and broad defaults last. <Callout type="warn"> If a visitor matches a boolean condition set but falls outside that set's rollout percentage, Rybbit returns `false` and does not continue to later condition sets. </Callout> ### Targeting Fields [#targeting-fields] | Field | Description | | --------------- | ---------------------------------------------------------------- | | Hostname | Current hostname, such as `example.com` | | Pathname | Current path, such as `/pricing` | | Query parameter | A specific URL query parameter | | Referrer | The document referrer | | Language | Browser language, such as `en-US` | | Country | Visitor country resolved from IP | | Region | Visitor region resolved from IP | | City | Visitor city resolved from IP | | Device type | Device category derived from user agent and screen size | | User ID | The identified user ID, falling back to the anonymous visitor ID | | User trait | A trait set through `identify()` or `setTraits()` | ### Operators [#operators] Targeting rules support: * `equals` * `not_equals` * `contains` * `starts_with` * `ends_with` * `regex` All rules in a condition set must match. Multiple condition sets act as ordered alternatives. ## Browser Runtime [#browser-runtime] The tracking script automatically evaluates client and both-runtime flags when it loads. It sends context such as anonymous visitor ID, identified user ID, URL, referrer, language, screen size, IP-derived location, and saved user traits. Use `onReady()` before reading flags if you need the evaluated value during page initialization: ```javascript window.rybbit.onReady((rybbit) => { const enabled = rybbit.flag("new_checkout", false); const payload = rybbit.flagPayload("new_checkout", {}); }); ``` Available browser methods: <TypeTable type="{ "flag(key, fallback)": { description: "Returns a single flag value. Reading a flag also records a feature flag exposure event once per flag value/version.", type: "function", }, "flagPayload(key, fallback)": { description: "Returns the payload for a flag or variant. Use this for remote config and multivariate payloads.", type: "function", }, "flags()": { description: "Returns all evaluated flag values keyed by flag key.", type: "function", }, "flagPayloads()": { description: "Returns all available flag payloads keyed by flag key.", type: "function", }, }" /> Assignments are included on analytics events as a feature flag map. This lets you filter dashboard reports by flag key and value. ### Identify and Traits [#identify-and-traits] If you call `identify()` or `setTraits()`, the browser runtime refreshes feature flags using the updated user context. ```javascript window.rybbit.identify("user_123", { plan: "pro", beta: true, }); ``` You can then target flags using **User ID** or **User trait** rules. ## Server Runtime [#server-runtime] Use server runtime for decisions that should not happen in the browser. The server endpoint returns server and both-runtime flags only. ``` POST /api/sites/:siteId/feature-flags/evaluate ``` ### Request [#request] <TypeTable type="{ anonymousId: { description: "Stable anonymous visitor ID used for rollout bucketing. Keep this stable for sticky assignments.", type: "string", required: true, }, identifiedUserId: { description: "Logged-in user ID. Enables user ID rules and loads saved user traits.", type: "string", }, hostname: { description: "Hostname used by hostname targeting rules.", type: "string", }, pathname: { description: "Path used by pathname targeting rules.", type: "string", }, query: { description: "Parsed query parameter object used by query targeting rules.", type: "Record<string, string>", }, querystring: { description: "Raw query string. Used if query is not provided.", type: "string", }, referrer: { description: "Referrer used by referrer targeting rules.", type: "string", }, language: { description: "Language code used by language targeting rules.", type: "string", }, screenWidth: { description: "Screen width used with user agent parsing to derive device type.", type: "number", }, screenHeight: { description: "Screen height used with user agent parsing to derive device type.", type: "number", }, }" /> <Tabs items="['cURL', 'JavaScript']"> <Tab value="cURL"> ```bash curl -X POST "https://app.rybbit.io/api/sites/123/feature-flags/evaluate" \ -H "Authorization: Bearer rb_your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "anonymousId": "visitor_abc", "identifiedUserId": "user_123", "hostname": "example.com", "pathname": "/checkout", "query": { "plan": "pro" }, "language": "en-US", "screenWidth": 1440, "screenHeight": 900 }' ``` </Tab> <Tab value="JavaScript"> ```javascript const response = await fetch( "https://app.rybbit.io/api/sites/123/feature-flags/evaluate", { method: "POST", headers: { Authorization: "Bearer rb_your_api_key_here", "Content-Type": "application/json", }, body: JSON.stringify({ anonymousId: "visitor_abc", identifiedUserId: "user_123", hostname: "example.com", pathname: "/checkout", query: { plan: "pro" }, language: "en-US", screenWidth: 1440, screenHeight: 900, }), } ); const data = await response.json(); const newCheckout = data.flags.new_checkout?.value === true; ``` </Tab> </Tabs> ### Response [#response] ```json { "flags": { "new_checkout": { "key": "new_checkout", "value": true, "flagType": "boolean", "payload": { "copy": "Try it now" }, "conditionSet": "Default", "version": 3, "reason": "rollout", "matched": true, "rolloutPercentage": 100 } }, "generatedAt": "2026-05-27T12:00:00.000Z" } ``` Assignment fields: | Field | Description | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `value` | The evaluated flag value. Boolean flags return `true` or `false`; multiple variant flags return a variant key or `false`; remote config returns `true` when matched. | | `payload` | Optional payload from the selected condition set or selected variant. | | `variant` | Selected variant key for multiple variant flags. | | `conditionSet` | Name of the condition set that matched. | | `reason` | Why the assignment was produced, such as `rollout`, `variant`, `remote_config`, `fallthrough`, `target_mismatch`, or `disabled`. | | `matched` | Whether the flag produced an active match. | | `rolloutPercentage` | The rollout percentage that was evaluated. | | `version` | Flag version. Increments when the flag is edited. | ## Analytics and Filtering [#analytics-and-filtering] Feature flag assignments are stored on events. Browser-tracked events include evaluated client flags automatically. Server-side events can include a `feature_flags` object in the [HTTP API](/docs/api/sending-events) payload: ```json { "site_id": "123", "type": "custom_event", "event_name": "purchase", "pathname": "/checkout", "feature_flags": { "new_checkout": "true", "homepage_cta": "variant_a" } } ``` Use feature flag filters in the dashboard to compare behavior between flag values. ## Best Practices [#best-practices] * Keep flag keys stable and descriptive, such as `new_checkout` or `pricing_page_copy`. * Use **Client** runtime only for values that are safe to expose in the browser. * Use **Server** runtime for permission checks, pricing logic, and private backend behavior. * Put specific condition sets before broad default condition sets. * Keep remote config payloads small and JSON-serializable. * Use a stable `anonymousId` for server-side evaluation so rollout assignments remain sticky. * Remove stale flags after rollout is complete to keep your dashboard and codebase clean. **Related Documentation:** * [Tracking Script](/docs/script) * [Identify Users](/docs/identify-users) * [Tagging](/docs/tagging) * [API - Sending Events](/docs/api/sending-events) # Filter traffic (/docs/filter-traffic) Rybbit provides server-side traffic filtering to keep unwanted data out of your analytics. You can exclude your own traffic, staging and preview environments, internal tools, monitoring checks, or entire countries. Filters are configured from **Site Settings → Exclusions**. ## Available Filters [#available-filters] | Filter | Use it for | Matching behavior | Limit | | ------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------- | ----------- | | **IP Exclusions** | Your home, office, VPN, or internal network traffic | Single IPs, CIDR ranges, and IPv4 ranges | 100 entries | | **Country Exclusions** | Regional filtering or compliance requirements | Two-letter ISO country codes, such as `US`, `GB`, or `DE` | 250 entries | | **Path Exclusions** | Admin pages, private pages, preview routes, or test pages | Case-insensitive glob patterns using `*` | 100 entries | | **Hostname Exclusions** | Localhost, staging domains, preview deployments, or temporary domains | Case-insensitive glob patterns using `*` | 100 entries | | **User Agent Exclusions** | Monitoring tools, headless browsers, or custom crawlers | Case-insensitive substring matching | 100 entries | When a request matches an exclusion, Rybbit returns a successful response but does not write the event to analytics storage. ## IP Exclusions [#ip-exclusions] Block traffic from specific IP addresses or ranges at the server level. This is usually the best way to hide your own traffic. Supported formats: * **Single IPv4**: `192.168.1.1` * **Single IPv6**: `2001:db8::1` * **CIDR notation**: `192.168.1.0/24`, `2001:db8::/32` * **IPv4 ranges**: `192.168.1.1-192.168.1.10` For setup instructions and best practices, see [Hiding Your Own Traffic](/docs/hiding-own-traffic). ## Country Exclusions [#country-exclusions] Block all traffic geolocated to specific countries. Rybbit resolves the visitor IP address with the MaxMind GeoLite2 database, then compares the resulting country code to your exclusion list. Use two-letter ISO country codes: * `US` for United States * `GB` for United Kingdom * `CN` for China * `DE` for Germany Country codes are stored as uppercase values in the dashboard and API. <Callout type="warning"> **Private IP addresses**: Country exclusion cannot work for private IP addresses such as `127.0.0.1`, `192.168.x.x`, `10.x.x.x`, or `172.16.x.x` because they cannot be geolocated. This includes localhost and Docker container IPs during local development. </Callout> <Callout type="info"> **VPNs and proxies**: Visitors using VPNs or proxies are detected based on the VPN or proxy server location, not their physical location. </Callout> ## Path Exclusions [#path-exclusions] Exclude traffic to specific page paths. Path exclusions match the pathname only, not the query string. Examples: ```txt /admin/* /preview /internal/* ``` Matching is case-insensitive. A `*` wildcard matches any sequence of characters, so `/admin/*` matches `/admin/users` and `/admin/settings`. Use path exclusions for pages that should not appear in analytics at all, such as admin dashboards, internal previews, or test routes. <Callout type="info"> For client-side-only pageview skipping, you can also use the tracking script's `data-skip-patterns` option. Site-level path exclusions are enforced on the server and also apply to session replay ingestion. </Callout> ## Hostname Exclusions [#hostname-exclusions] Exclude traffic from specific hostnames. This is useful when the same site ID is used across production, staging, preview, and local environments. Examples: ```txt localhost staging.example.com *.vercel.app *.netlify.app ``` Matching is case-insensitive. A `*` wildcard matches any sequence of characters, so `*.vercel.app` matches `my-preview.vercel.app` but not `vercel.app`. ## User Agent Exclusions [#user-agent-exclusions] Exclude traffic from requests whose user agent contains a configured substring. Examples: ```txt HeadlessChrome MyMonitor Pingdom ``` Matching is case-insensitive and uses substring matching, so `HeadlessChrome` matches `Mozilla/5.0 HeadlessChrome/120`. Use user-agent exclusions for uptime monitors, synthetic checks, internal crawlers, or other tools with predictable user-agent strings. ## What Filters Apply To [#what-filters-apply-to] Traffic filters run before normal analytics data is stored. They apply to: * Pageviews * Custom events * Performance and Web Vitals events * Error events * Autocapture events * Session replay batches ## How Filters Are Evaluated [#how-filters-are-evaluated] Rybbit checks exclusions in this order: 1. IP address 2. Country 3. Path 4. Hostname 5. User agent If any filter matches, the event is accepted with a `200` response but is not tracked. Changes usually take effect within 60 seconds because site configuration is cached briefly on the server. ## Testing Filters Locally [#testing-filters-locally] To test country filtering locally, send a public IP through the `X-Real-IP` header: ```bash curl -X POST http://localhost:3001/api/track \ -H "Content-Type: application/json" \ -H "X-Real-IP: 8.8.8.8" \ -d '{ "type": "pageview", "site_id": "your-site-id", "hostname": "example.com", "pathname": "/test" }' ``` For path and hostname filters, set `pathname` and `hostname` in the event payload to values that should match your configured exclusions. # Funnels (/docs/funnels) <img alt="Funnels Dashboard" src="__img0" /> Funnels let you define a sequence of steps — page visits, custom events, or autocaptured interactions — and measure how many users progress through each stage. You can see where users convert, where they drop off, and drill into the sessions at each step to understand why. ## Creating a Funnel [#creating-a-funnel] <img alt="Creating a Funnel" src="__img1" /> <Steps> <Step> ### Click "Create Funnel" [#click-create-funnel] Each funnel has: * **Name** — A label for the funnel (e.g., "Signup Flow", "Checkout Process") * **Steps** — Two or more sequential steps, each tracking a page visit, custom event, outbound link, button click, form submission, or copy event </Step> <Step> ### Add Steps [#add-steps] A funnel always must have at least two steps. There are six types of funnel steps. Path and custom event steps require a value. For the four autocaptured interaction types, the pattern is optional—leave it empty to match every interaction of that type. <Tabs items="['Path', 'Custom Event', 'Outbound Link', 'Button Click', 'Form Submit', 'Copy']"> <Tab value="Path"> Path steps match pageview events by URL path. * Enter a URL path starting with `/` * Wildcards are supported: * `*` matches a single path segment — `/products/*` matches `/products/shoes` * `**` matches multiple segments — `/docs/**` matches `/docs/api/v2/intro` </Tab> <Tab value="Custom Event"> Custom event steps match events by name (e.g., `add_to_cart`, `purchase_completed`). Events must be tracked using the Rybbit tracking script. See [Track Events](/docs/track-events) for setup instructions. </Tab> <Tab value="Outbound Link"> Outbound link steps match clicks on links that lead away from your site. * Enter a destination URL pattern such as `https://example.com/pricing` or `https://*.example.com/**` * Leave the pattern empty to match any outbound click Outbound link tracking is enabled by default. </Tab> <Tab value="Button Click"> Button click steps match clicks on buttons, button/submit inputs, and elements with `role="button"`. * Enter a visible button-text pattern such as `Sign Up` or `Start * Trial` * Leave the pattern empty to match any button click Buttons that already use `data-rybbit-event` are recorded as custom events instead. </Tab> <Tab value="Form Submit"> Form submit steps match submissions of HTML forms. * Enter a form name or ID pattern such as `signup-form` * The pattern can also match the form's action URL * Leave the pattern empty to match any form submission </Tab> <Tab value="Copy"> Copy steps match text that visitors copy from your site. * Enter a copied-text pattern such as `PROMO*` * Leave the pattern empty to match any copied text </Tab> </Tabs> Autocaptured interaction patterns support wildcards: `*` matches one or more characters and `**` also matches an empty value. The form suggests values already observed on your site. <Callout type="info" title="Enable autocapture first"> Button click, form interaction, and copy tracking are disabled by default. Enable the event types you want to use in the **Tracking** tab of your site settings. Only interactions collected after tracking is enabled can appear in a funnel. See [Autocapture](/docs/autocapture) for the properties collected with each type. </Callout> </Step> <Step> ### Configure Step Options (optional) [#configure-step-options-optional] <img alt="Step Options" src="__img2" /> Each step has additional configuration options: * **Label** (optional) — A friendly display name for the step * **Hostname** (optional) — Restrict matching to a specific domain, useful for multi-domain sites * **Property Filters** (optional) — Filter by URL parameters (for path steps) or event properties (for all other step types). Multiple filters can be added per step, and all must match. </Step> </Steps> ## Reading Funnel Results [#reading-funnel-results] The funnel visualization updates in real time as you configure steps. For each step, you'll see: * **Sessions** — How many sessions reached this step * **Conversion Rate** — Percentage of the first step's sessions that reached this step * **Dropped** — How many sessions completed the previous step but not this one * **Dropoff Rate** — Percentage of the previous step's sessions lost at this point The horizontal bar for each step shows two layers: * **Solid bar** — Overall conversion from the first step * **Striped overlay** — Conversion from the immediately previous step Steps are evaluated sequentially — each step must occur after the previous one within the same session. ## Viewing Sessions [#viewing-sessions] <img alt="Viewing Sessions" src="__img3" /> Click any step to expand it and view the sessions associated with that step: * **Reached** tab — Sessions that completed this step * **Dropped Off** tab — Sessions that reached the previous step but didn't make it to this one (available for step 2 and beyond) Each session shows entry/exit pages, duration, location, device info, referrer, UTM parameters, and user ID if identified. Sessions are paginated. *** **Related Documentation:** * [Autocapture](/docs/autocapture) — Configure automatically captured interactions * [Goals](/docs/goals) — Track single-action conversions * [Track Events](/docs/track-events) — Set up custom event tracking # Goals (/docs/goals) <img alt="Goals Dashboard" src="__img0" /> Goals let you define specific actions — from visiting a page to clicking a button — and measure how often your visitors complete them. Each goal tracks a conversion rate against total sessions, giving you a clear picture of how effectively your site drives key outcomes. ## Creating a Goal [#creating-a-goal] <img alt="Creating a Goal" src="__img1" /> <Steps> <Step> ### Click "Create Goal" [#click-create-goal] Every goal has: * **Name** (optional) — A friendly label like "Completed Checkout" or "Signed Up" * **Type** — Page, custom event, outbound link, button click, form submission, or copied text * **Value** — A required path or custom event name, or an optional pattern that narrows an autocaptured interaction </Step> <Step> ### Choose a Goal Type [#choose-a-goal-type] There are six goal types. Page and custom event goals require a value. For the four autocaptured interaction types, the pattern is optional—leave it empty to count every interaction of that type. <Tabs items="['Page', 'Custom Event', 'Outbound Link', 'Button Click', 'Form Submit', 'Copy']"> <Tab value="Page"> Page goals track when a user visits a URL matching a given path pattern. * The value must be a path starting with `/` (not a full URL) * Wildcards are supported: * `*` matches a single path segment — `/blog/*` matches `/blog/my-post` * `**` matches multiple segments — `/docs/**` matches `/docs/api/v2/intro` The form provides autocomplete suggestions based on actual paths seen on your site. </Tab> <Tab value="Custom Event"> Custom event goals track when an event with a specific name is fired. The value is the event name (e.g., `signup_clicked`, `purchase_completed`). Events must be tracked using the Rybbit tracking script. See [Track Events](/docs/track-events) for setup instructions. </Tab> <Tab value="Outbound Link"> Outbound link goals track clicks on links that lead away from your site. * Enter a destination URL pattern such as `https://example.com/pricing` or `https://*.example.com/**` * Leave the pattern empty to count clicks to any external URL Outbound link tracking is enabled by default. </Tab> <Tab value="Button Click"> Button click goals track clicks on buttons, button/submit inputs, and elements with `role="button"`. * Enter a visible button-text pattern such as `Sign Up` or `Start * Trial` * Leave the pattern empty to count any button click Buttons that already use `data-rybbit-event` are recorded as custom events instead. </Tab> <Tab value="Form Submit"> Form submit goals track submissions of HTML forms. * Enter a form name or ID pattern such as `signup-form` * The pattern can also match the form's action URL * Leave the pattern empty to count any form submission </Tab> <Tab value="Copy"> Copy goals track text that visitors copy from your site. * Enter a copied-text pattern such as `PROMO*` * Leave the pattern empty to count any copied text </Tab> </Tabs> Autocaptured interaction patterns support wildcards: `*` matches one or more characters and `**` also matches an empty value. The form suggests values already observed on your site. <Callout type="info" title="Enable autocapture first"> Button click, form interaction, and copy tracking are disabled by default. Enable the event types you want to use in the **Tracking** tab of your site settings. Only interactions collected after tracking is enabled can count toward a goal. See [Autocapture](/docs/autocapture) for the properties collected with each type. </Callout> </Step> <Step> ### Add Property Filters (optional) [#add-property-filters-optional] <img alt="Property Filters" src="__img2" /> You can narrow a goal to only count conversions when specific properties match: * **Page goals** — Filter by URL parameters (e.g., only count visits where `utm_source=adwords`) * **Custom event goals** — Filter by event properties (e.g., only count events where `plan=premium`) * **Autocaptured interaction goals** — Filter by captured properties such as `url`, `text`, `formName`, `formId`, or `formAction` Toggle **Match specific URL parameters** or **Match specific event properties** to add filters. You can add multiple filters per goal — all filters must match for a session to count as a conversion. </Step> </Steps> ## Goal Metrics [#goal-metrics] Each goal card displays: * **Total Conversions** — The number of distinct sessions that completed the goal * **Conversion Rate** — Conversions divided by total sessions in the selected time period, shown as a percentage with a visual progress bar Goals respect the global date range and filters (country, device, browser, etc.) applied on the page. ## Viewing Converted Sessions [#viewing-converted-sessions] <img alt="Viewing Converted Sessions" src="__img3" /> Click a goal card to expand it and see the sessions that converted. Each session shows: * Entry and exit pages * Session duration * Location (country, region, city) * Device, browser, and OS * Referrer, channel, and UTM parameters * User ID (if identified) Sessions are sorted by most recent first and paginated. *** **Related Documentation:** * [Autocapture](/docs/autocapture) — Configure automatically captured interactions * [Track Events](/docs/track-events) — Set up custom event tracking * [Funnels](/docs/funnels) — Analyze multi-step conversion paths # Hiding your own traffic (/docs/hiding-own-traffic) There are several ways to disable Rybbit tracking to hide your own visits from analytics. <Tabs items="['IP Exclusion', 'Browser Extensions', 'localStorage Method']"> <Tab> **IP Exclusion (Recommended)**: Block traffic from specific IP addresses or ranges at the server level for complete accuracy. ### Setting Up IP Exclusion [#setting-up-ip-exclusion] 1. **Navigate to Site Settings**: Go to your site's settings page in the Rybbit dashboard 2. **Find IP Exclusions section**: Scroll down to the "IP Exclusions" configuration 3. **Add your IP addresses**: Enter the IP addresses or ranges you want to exclude ### Supported IP Formats [#supported-ip-formats] Rybbit supports multiple IP exclusion formats: * **Single IPv4**: `192.168.1.1` * **Single IPv6**: `2001:db8::1`, `::1` * **CIDR notation**: `192.168.1.0/24`, `2001:db8::/32` * **IPv4 ranges**: `192.168.1.1-192.168.1.10` (IPv4 only) ### Examples [#examples] ``` # Your home IP address 203.0.113.45 # Your office network (CIDR) 203.0.113.0/24 # IP range for your team 198.51.100.10-198.51.100.20 # IPv6 address 2001:db8::1 ``` ### Key Features [#key-features] * **Server-side filtering**: Traffic is blocked before it reaches your analytics, providing highly accurate exclusion * **Multiple formats**: Supports individual IPs, CIDR notation, and ranges * **IPv4 and IPv6**: Full support for both IP versions * **Near real-time updates**: Changes take effect within seconds * **No browser dependency**: Works regardless of browser or device <Callout type="info"> **IP Stability Note**: IP addresses can change due to factors like mobile networks, VPN usage, ISP changes, and IPv6 privacy extensions. You may need to periodically update your excluded IPs to maintain effective filtering. </Callout> <Callout type="info"> **Dynamic IP Addresses**: If you frequently use VPNs, mobile networks, or have a dynamic IP address that changes regularly, consider these approaches: * Use CIDR ranges that cover your typical IP pool (consult your ISP or VPN provider) * Combine IP exclusion with browser extensions for comprehensive coverage * Check your current IP periodically and update exclusions as needed * For corporate networks, exclude the entire office IP range rather than individual addresses </Callout> <Callout type="warning"> **Note**: IPv6 range notation (e.g., `2001:db8::1-2001:db8::ff`) is not supported. Use CIDR notation instead (e.g., `2001:db8::/32`). </Callout> <Callout type="info"> **Finding your IP**: You can find your current IP address by visiting sites like [whatismyipaddress.com](https://whatismyipaddress.com) or [ipinfo.io](https://ipinfo.io). </Callout> </Tab> <Tab> **Browser Extensions**: Easy client-side solution using community-maintained browser extensions. ### Available Extensions [#available-extensions] * **[Chrome Extension](https://chrome.google.com/webstore/detail/gofnfdfidbkpellionhpmkejgaajieoo)**: Install from the Chrome Web Store * **[Firefox Extension](https://addons.mozilla.org/en-US/firefox/addon/rybbit-opt-out/)**: Install from Firefox Add-ons These extensions automatically disable Rybbit tracking on all websites where you have Rybbit installed, without requiring any manual configuration. <Callout type="info"> **Self-hosted users**: These extensions require Rybbit version 1.1.0 or higher. If you're running an older version, please update your installation or use the localStorage method. </Callout> *These extensions are community-maintained by [@davidfiala](https://github.com/davidfiala).* </Tab> <Tab> **localStorage Method**: Manual configuration using your browser's developer console for more granular control. <Steps> ### 1. Open your browser's developer console [#1-open-your-browsers-developer-console] Press **F12** or right-click the page and select "Inspect" Navigate to the "Console" tab ### 2. Disable tracking [#2-disable-tracking] Enter this command to disable tracking: ```js localStorage.setItem('disable-rybbit', 'true'); ``` ### 3. Re-enable tracking (when needed) [#3-re-enable-tracking-when-needed] If you want to re-enable tracking later, enter: ```js localStorage.removeItem('disable-rybbit'); ``` </Steps> </Tab> </Tabs> ## Summary [#summary] Multiple methods are available for hiding your own traffic, each with different advantages: * **IP exclusion** provides the most accurate solution by blocking traffic at the server level, working regardless of browser or device * **Browser extensions** offer convenience by automatically working across all your Rybbit-enabled websites * **localStorage method** gives granular control for specific websites or temporary disabling For most users, **IP exclusion is recommended** as it provides highly accurate filtering for all devices accessing your site from the excluded IP addresses. The other methods work at the browser level and may be useful for additional flexibility or temporary exclusions. # Identify Users (/docs/identify-users) <Callout type="warn" title="GDPR & Privacy Compliance"> User identification involves processing personal data. Before using these features, ensure you have a lawful basis for processing (typically user consent), have disclosed tracking in your privacy policy, and provide users the ability to access and delete their data. Only collect traits you have explicit consent to process. </Callout> Rybbit allows you to identify logged-in users and associate their activity with a persistent identity across devices and sessions. This enables powerful user-level analytics, including tracking user journeys, understanding retention, and personalizing experiences. <img alt="identify users" src="__img0" /> ## How It Works [#how-it-works] By default, Rybbit assigns an anonymous ID to each visitor based on their device fingerprint. When you call `identify()`, you link this anonymous visitor to a known user ID, allowing you to: * Track the same user across multiple devices * Associate pre-login activity with the logged-in user * Store user metadata (traits) like email, name, and custom fields * View identified users in your dashboard with their real names When a new alias is created (i.e., the first time `identify()` is called for a given device), Rybbit retroactively updates all past anonymous events from that device to include the identified user ID. This means pre-login pageviews and events are attributed to the user once they identify themselves. ## Special Trait Fields [#special-trait-fields] When identifying users, certain trait fields are treated specially by Rybbit: | Field | Description | | ---------- | ------------------------------------------------------------------------------------------- | | `username` | **Primary display name** - Shown in the users list and session cards instead of the user ID | | `name` | **Fallback display name** - Used if username is not provided | | `email` | Displayed below the user's name in the dashboard | You can also include any custom fields you need for segmentation and analysis. ## Limits [#limits] * **Traits size**: Maximum 2KB per identify call. Traits are stored as JSON, so keep payloads concise. * **User ID length**: Maximum 255 characters. ## API Reference [#api-reference] ### `window.rybbit.identify(userId, traits?)` [#windowrybbitidentifyuserid-traits] Sets a custom user ID for tracking logged-in users across devices and sessions, with optional user traits (metadata). * `userId` (String): The unique identifier for the user. This will be stored in localStorage and persist across browser sessions. **Note: Custom user IDs are stored exactly as provided without any hashing or modification.** * `traits` (Object, Optional): An object containing user metadata like email, name, username, or any custom fields. This data is stored in Rybbit and can be used to identify users in the dashboard. Setting a trait to `null` removes it from the user's profile. ```javascript // Identify a user when they log in (without traits) window.rybbit.identify("user_12345"); // Identify a user with traits (recommended) window.rybbit.identify("user_12345", { username: "johndoe", // Primary display name name: "John Doe", // Fallback display name email: "john@example.com", plan: "premium", company: "Acme Inc" }); // All subsequent events and pageviews will be associated with this user ID ``` ### `window.rybbit.setTraits(traits)` [#windowrybbitsettraitstraits] Updates the traits for the currently identified user without re-identifying them. Useful for updating user properties after initial identification. * `traits` (Object): An object containing user metadata. New traits are merged with existing traits. **Setting a trait to `null` removes it from the user's profile.** ```javascript // Update user traits after they upgrade their plan window.rybbit.setTraits({ plan: "enterprise", upgraded_at: "2024-01-15" }); // Remove a trait by setting it to null window.rybbit.setTraits({ temporary_flag: null // This trait will be removed }); ``` **Note:** You must call `identify()` before using `setTraits()`. If no user is identified, this will log a warning. ### `window.rybbit.clearUserId()` [#windowrybbitclearuserid] Clears the stored user ID. Useful when a user logs out. ```javascript // Clear user identification when user logs out window.rybbit.clearUserId(); ``` ### `window.rybbit.getUserId()` [#windowrybbitgetuserid] Returns the currently set user ID, or `null` if none is set. ```javascript // Check if a user is identified const currentUserId = window.rybbit.getUserId(); if (currentUserId) { console.log('Current user:', currentUserId); } else { console.log('No user identified'); } ``` ## Important Notes [#important-notes] * **Unique user counts**: Every user count in the dashboard — Unique Users, the users chart, the Users table, traits, and retention — keys on the custom user ID wherever one is present, falling back to the anonymous fingerprint only for visitors who have never identified. Two people sharing an IP address and browser count as two users, and one person on a laptop and a phone counts as one. Sites that never call `identify()` are unaffected. Because the count follows the IDs you pass, sending a fresh ID per visit (rather than a stable per-person ID) will inflate it. * **Identified sessions**: When a custom user ID is present, session assignment includes both the anonymous device fingerprint and the custom user ID. Different identified users therefore remain in separate sessions even when they share the same IP address and user agent. * **Shared devices**: If multiple users log in from the same device (same IP address and user agent), calling `identify()` with a new user ID updates the alias to point to the new user. Future events will be attributed to the most recently identified user. Past events that were already backfilled retain their original attribution. * **Cross-device tracking**: Identifying the same user on different devices creates separate aliases. Events from each device are linked to the user independently via the `identified_user_id` field. * **Retroactive backfill**: The backfill runs asynchronously when a new alias is first created. ClickHouse mutations may take a short time to propagate depending on table size. Subsequent `identify()` calls from the same device (even with a different user ID) do not re-backfill old events. * **`clearUserId()` behavior**: Calling `clearUserId()` removes the user ID from localStorage so future events are anonymous. It does not delete the alias in the database or modify previously attributed events. ## Complete Example [#complete-example] Here's a typical workflow for user identification: ```javascript // When user logs in function handleLogin(userData) { // Set user identification with traits window.rybbit.identify(userData.id, { username: userData.username, name: userData.fullName, email: userData.email, plan: userData.plan }); // Track login event window.rybbit.event("User Login", { method: "email" }); } // When user upgrades their plan function handlePlanUpgrade(newPlan) { // Update traits without re-identifying window.rybbit.setTraits({ plan: newPlan, upgraded_at: new Date().toISOString() }); // Track upgrade event window.rybbit.event("Plan Upgraded", { new_plan: newPlan }); } // When user logs out function handleLogout() { // Track logout event window.rybbit.event("User Logout"); // Clear user identification window.rybbit.clearUserId(); } // Check identification status function checkUserStatus() { const userId = window.rybbit.getUserId(); if (userId) { console.log("User is identified:", userId); } else { console.log("Anonymous user"); } } ``` **Related Documentation:** * [Track Events](/docs/track-events) * [Behavior Analytics - Users Tab](/docs/behavior-analytics/users) * [API - Sending Events](/docs/api/sending-events) # Introduction (/docs) **Rybbit** is a powerful open source web and product analytics platform. Check out our [live demo](https://demo.rybbit.com/81) using a real production site. <DocsHomeCards /> ## Getting Started [#getting-started] Choose the option that's right for you: * **Cloud Version:** Sign up for the [hosted version](https://app.rybbit.io/signup). Get up and running in minutes. * **Self-Host:** Follow our [self hosting guide](/docs/self-hosting) to set up Rybbit on your own server. And then learn how to add the [tracking script](/docs/script) to your website to start collecting data. ## Code [#code] Rybbit is open sourced under the AGPL-3.0 license. You can find the code on [GitHub](https://github.com/rybbit-io/rybbit). ## Community [#community] Join our [Discord server](https://discord.gg/DEhGb4hYBj) or talk to me on [X](https://x.com/yang_frog). I'm always looking for feedback and ideas to make Rybbit as awesome as possible. # Inviting users (/docs/inviting-users) <Callout type="info"> On self-hosted instances, this workflow is slightly different. Instead of inviting users, you can create a new user and set their password directly since self-hosted instances don't send emails. </Callout> <Steps> <Step> ### Add the user to your organization [#add-the-user-to-your-organization] Go to **Settings > Organization** and click the "Invite Member" button. Enter the user's email address. </Step> <Step> ### Select the user's role [#select-the-users-role] You can assign the user one of three roles: **Owners** have full access to all features including billing, organization deletion, and member management. **Admins** can manage members and access all sites, but cannot delete the organization or manage billing. **Members** can view analytics for assigned sites only. They cannot manage settings or other members. #### Restricting site access for members [#restricting-site-access-for-members] When inviting a member, you can optionally restrict their access to specific sites. Check the "Restrict access to specific sites" option and select which sites the member should be able to view. If you don't enable this option, the member will have access to all sites in your organization. <Callout type="info"> Site access restrictions only apply to members. Admins and owners always have full access to all sites. </Callout> </Step> <Step> ### Assign teams (optional) [#assign-teams-optional] If your organization has [teams](/docs/teams) set up, you can assign the new member to a team during the invitation. When the member accepts the invitation, they will automatically be added to the selected team. This is useful for ensuring new members immediately have access to the right group of sites without needing to configure access separately. </Step> <Step> ### Send the invite [#send-the-invite] Click the "Invite" button. The user will receive an email with a link to sign up for Rybbit. You can manage all pending invitations in the Organization settings page. </Step> </Steps> ## Managing site access for existing members [#managing-site-access-for-existing-members] You can change a member's site access at any time after they've joined your organization. 1. Go to **Settings > Organization** 2. Find the member in the list and click the edit (pencil) icon in the Actions column for that member 3. Toggle "Restrict access to specific sites" and select the sites they should have access to 4. Click "Save changes" The member will immediately only see the sites you've selected. If you disable the restriction, they'll regain access to all sites. <Callout type="info"> Currently, each user can only be in one organization. </Callout> # Localhost tracking (/docs/localhost-tracking) Rybbit does not restrict tracking to your registered domain — the tracking script works from `localhost` (or any other origin) out of the box. As long as your snippet has a valid `data-site-id`, events sent during local development are recorded like any other traffic. ```html <script defer src="https://app.rybbit.io/api/script.js" data-site-id="YOUR_SITE_ID"> </script> ``` <Callout type="warning"> Because localhost traffic is tracked by default, the usual problem is the opposite one: development visits polluting your production analytics. Use one of the approaches below to keep your data clean. </Callout> ## Keeping development traffic out of production data [#keeping-development-traffic-out-of-production-data] ### Option 1: Use a separate development site [#option-1-use-a-separate-development-site] Create a second site in your Rybbit dashboard (e.g. "My App (dev)") and use its site ID during development. Your production site's data stays untouched, and you can still verify events end-to-end. ```jsx // Next.js example export function Analytics() { const siteId = process.env.NODE_ENV === 'development' ? process.env.NEXT_PUBLIC_RYBBIT_DEV_SITE_ID : process.env.NEXT_PUBLIC_RYBBIT_SITE_ID; return ( <script defer src="https://app.rybbit.io/api/script.js" data-site-id={siteId} /> ); } ``` ### Option 2: Only load the script in production [#option-2-only-load-the-script-in-production] If you don't need analytics while developing, skip loading the script entirely outside production: ```jsx // Next.js example export function Analytics() { if (process.env.NODE_ENV !== 'production') return null; return ( <script defer src="https://app.rybbit.io/api/script.js" data-site-id={process.env.NEXT_PUBLIC_RYBBIT_SITE_ID} /> ); } ``` ```javascript // Vanilla JS example if (process.env.NODE_ENV === 'production') { const script = document.createElement('script'); script.defer = true; script.src = 'https://app.rybbit.io/api/script.js'; script.setAttribute('data-site-id', 'YOUR_SITE_ID'); document.head.appendChild(script); } ``` ### Option 3: Filter the traffic instead [#option-3-filter-the-traffic-instead] If you want the script loaded everywhere but excluded from your stats, use your site's exclusion settings — see [Filtering Traffic](/docs/filter-traffic) and [Hiding Your Own Traffic](/docs/hiding-own-traffic). ## Troubleshooting [#troubleshooting] **Events not showing up from localhost?** * Confirm `data-site-id` matches the site you're looking at in the dashboard * Check the browser console for errors and the Network tab for `POST` requests to `/api/track` * Disable ad blockers or privacy extensions — they commonly block analytics scripts on any origin, including localhost * Make sure the page isn't excluded by your site's IP, path, or user-agent exclusion rules # Managing your installation (/docs/managing-your-installation) ## Helpful scripts [#helpful-scripts] <Callout> You probably need to run `chmod +x *.sh` before running the scripts. </Callout> ### Updating Your Installation [#updating-your-installation] Rybbit is updated frequently with new features and bug fixes. You can update your installation by running the following commands: **update.sh**: Update to the latest version by pulling new code and rebuilding containers ```bash git pull ./update.sh ``` ### Restarting Your Installation [#restarting-your-installation] **restart.sh**: Restart all services (useful when you've changed .env settings) ```bash ./restart.sh ``` ## Docker Compose Commands [#docker-compose-commands] You can also manage services directly with Docker Compose: * **Stop all services**: ```bash docker compose stop ``` * **Stop and remove containers** (preserves data): ```bash docker compose down ``` * **Start services** (if you used setup.sh with default webserver): ```bash docker compose --profile with-webserver up -d ``` * **Start services** (if you used setup.sh with --no-webserver): ```bash docker compose up -d ``` * **View logs**: ```bash docker compose logs -f ``` <Callout> If you are using your self-hosted Rybbit without anyone else, you can disable new user signups by setting `DISABLE_SIGNUP=true` in the `.env` file at the root of the repository. </Callout> ## Database Access [#database-access] You can access the databases by running the following commands: * **Access Clickhouse**: ```bash docker exec -it clickhouse clickhouse-client --database analytics ``` * **Access Postgres**: ```bash docker exec -it postgres psql -U frog -d analytics ``` ### Edit Session Replay Retention Policy [#edit-session-replay-retention-policy] The default session replay retention policy is 30 days. This may be too long for some users as session replays can take up a lot of space. You can edit it by running the following command: ```bash docker exec -it clickhouse clickhouse-client --database analytics ALTER TABLE session_replay_events MODIFY TTL toDateTime(timestamp) + INTERVAL 14 DAY; ALTER TABLE session_replay_metadata MODIFY TTL start_time + INTERVAL 14 DAY; ``` ## Anonymous Usage Telemetry [#anonymous-usage-telemetry] To help us improve Rybbit, self-hosted instances automatically send anonymous usage statistics to our cloud service. This includes: * Number of rows in a few core ClickHouse tables (events and the session replay tables) * Total database size * Your Rybbit version * A hashed instance identifier No personal data, website content, or visitor information is ever collected. <Callout type="info"> You can completely disable telemetry by adding `DISABLE_TELEMETRY=true` to your `.env` file and restarting your services. </Callout> # Organization settings (/docs/organization-settings) Access organization settings by clicking on "Organization" or going to [app.rybbit.io/settings/organization](https://app.rybbit.io/settings/organization). <img alt="organization settings" src="__img0" /> ## Organization [#organization] ### Organization Name [#organization-name] Update your organization's display name. Only the organization owner can change this setting. ### Delete Organization [#delete-organization] Permanently delete the organization and all associated data including sites, analytics, reports, and member associations. <Callout type="warn"> This action cannot be undone. You must type the organization name to confirm deletion. If you have an active subscription, you must cancel it before deleting the organization. </Callout> ## Members [#members] View and manage all members in your organization. The members table shows: * **Name** - Member's display name * **Email** - Member's email address * **Role** - Owner, Admin, or Member * **Site Access** - Which sites the member can access * **Joined** - When they joined the organization ### Member Roles [#member-roles] | Role | Permissions | | ---------- | ----------------------------------------------------------------------------------------- | | **Owner** | Full access to all features, can manage members, billing, and delete the organization | | **Admin** | Can manage members and access all sites, cannot delete the organization or manage billing | | **Member** | Can view analytics for assigned sites only, cannot manage settings or other members | ### Inviting Members [#inviting-members] Owners can invite new members or create user accounts. See the [Inviting Users](/docs/inviting-users) guide for detailed instructions. ### Teams [#teams] You can organize sites into teams to control which members have access to which sites. See the [Teams](/docs/teams) guide for details. # Roadmap (/docs/roadmap) Rybbit is a quickly growing project. We already have quite a few features, but there is much more to come. ## Planned features [#planned-features] * Data import from Google Analytics, PostHog, and more * More tracking script/SDK integration guides * Custom themes Once again, join our [Discord server](https://discord.gg/DEhGb4hYBj) or follow me on [X](https://x.com/yang_frog) to share your feedback and ideas. We would love to hear from you! # Troubleshooting (/docs/script-troubleshooting) If you've installed the Rybbit tracking script but aren't seeing any traffic in your dashboard, this guide will help you diagnose and fix common issues. ## Check if the Script is Loaded [#check-if-the-script-is-loaded] The first step is to verify that the Rybbit script is properly loaded on your page. <Steps> <Step> ### Open Your Browser's Developer Console [#open-your-browsers-developer-console] Right-click anywhere on your webpage and select **Inspect** (or **Inspect Element**). Then click on the **Console** tab at the top of the panel that opens. Alternatively, you can use keyboard shortcuts: * **Windows/Linux**: Press `Ctrl + Shift + J` or `F12` * **Mac**: Press `Cmd + Option + J` </Step> <Step> ### Type "rybbit" and Press Enter [#type-rybbit-and-press-enter] In the console, type `rybbit` and press Enter. **If the script is loaded correctly**, you'll see an object with functions like this: ```javascript {event: ƒ, pageview: ƒ, identify: ƒ, setTraits: ƒ, clearUserId: ƒ, …} ``` **If the script is NOT loaded**, you'll see an error like: ``` Uncaught ReferenceError: rybbit is not defined ``` If you see the error, the script is not loading properly. Continue reading for common causes. </Step> </Steps> ## Common Issues [#common-issues] ### Data Attributes Being Stripped [#data-attributes-being-stripped] Some platforms, content management systems, tag managers, and "delay JavaScript" optimizers automatically remove or sanitize `data-*` attributes from script tags, or rebuild the tag from a `data-src` placeholder and drop everything else. If your site ID was in a `data-site-id` attribute, it gets removed and the script logs `Please provide a valid site ID`. **Platforms known to strip attributes:** * Google Tag Manager * WordPress optimization plugins with "delay JavaScript" enabled (WP Rocket, Perfmatters, FlyingPress, LiteSpeed Cache) * Some WordPress page builders * Certain website builders (Webflow, Wix, Squarespace in some configurations) * Some CDN or caching plugins **Solution:** Put the site ID in the script URL instead of a data attribute. Optimizers preserve the URL: ```html <script src="https://app.rybbit.io/api/script.js?siteId=YOUR_SITE_ID" defer></script> ``` If your platform also rewrites the `src`, inject the script from JavaScript instead: ```javascript <script> (function() { var el = document.createElement("script"); el.src = "https://app.rybbit.io/api/script.js?siteId=YOUR_SITE_ID"; el.defer = true; document.head.appendChild(el); })(); </script> ``` <Callout type="info"> For detailed Google Tag Manager setup instructions, see our [Google Tag Manager Guide](/docs/guides/google-tag-manager). </Callout> ### Ad Blockers [#ad-blockers] In rare cases, ad blockers may block the Rybbit tracking script. While Rybbit is privacy-focused and doesn't use cookies for tracking, some aggressive ad blockers may still flag analytics scripts. **How to check if an ad blocker is the issue:** 1. Temporarily disable your ad blocker 2. Refresh the page 3. Check the console again for the `rybbit` object **Solutions:** * **For your own testing**: Disable your ad blocker or add your site to the allowlist * **For your visitors**: Consider setting up a [proxy](/docs/proxy-guide) to serve the script from your own domain, which significantly reduces the chance of being blocked ### Script Not in the Right Location [#script-not-in-the-right-location] The Rybbit script should be placed in the `<head>` section of your HTML. If it's placed in the wrong location or loaded after the page has already finished loading, it may not work correctly. **Verify the script placement:** 1. View your page source (right-click → **View Page Source**) 2. Search for `rybbit` or `script.js` 3. Confirm it appears within the `<head>...</head>` tags ### Incorrect Site ID [#incorrect-site-id] Double-check that the `siteId` in your script URL (or your `data-site-id` attribute) matches the site ID shown in your Rybbit dashboard. 1. Go to your Rybbit dashboard 2. Navigate to your site's settings 3. Copy the site ID and compare it with what's in your script tag ### Content Security Policy (CSP) Blocking [#content-security-policy-csp-blocking] If your site has a strict Content Security Policy, it may block the Rybbit script from loading or executing. **Check for CSP errors:** 1. Open the browser console 2. Look for errors mentioning "Content Security Policy" or "CSP" **Solution:** Add the Rybbit script domain to your CSP headers: ``` script-src 'self' https://app.rybbit.io; connect-src 'self' https://app.rybbit.io; ``` If you're self-hosting Rybbit, replace `app.rybbit.io` with your Rybbit instance domain. ### Script Loading Too Late in SPAs [#script-loading-too-late-in-spas] In single-page applications (React, Vue, Next.js, etc.), if the script loads after the initial page render, the automatic pageview might not fire. **Solution:** Ensure the script is in your root HTML template (like `index.html` or `_document.js` in Next.js), not dynamically imported in a component. For Next.js specifically, see our [Next.js Guide](/docs/guides/nextjs). ## Testing Your Installation [#testing-your-installation] Once you've made changes, verify that tracking is working: 1. **Check the console**: Type `rybbit` to confirm the object exists 2. **Trigger a test event**: In the console, run: ```javascript rybbit.event("test_event") ``` 3. **Check your dashboard**: Navigate to the Events tab in your Rybbit dashboard and look for "test\_event" (it may take a few seconds to appear) 4. **Check the Network tab**: In browser dev tools, go to the Network tab, filter by "track" or "rybbit", and verify requests are being sent with 200 status codes ## Still Having Issues? [#still-having-issues] If you've tried all the above and are still not seeing traffic: 1. **Check the browser Network tab** for any failed requests to your Rybbit instance 2. **Verify your site ID** is correct in the dashboard 3. **Check if traffic is being filtered** - review your [IP exclusions](/docs/hiding-own-traffic) and [country exclusions](/docs/filter-traffic) to ensure your traffic isn't being blocked 4. **For self-hosted instances** - ensure your Rybbit server is running and accessible *** **Related Documentation:** * [Tracking Script Installation](/docs/script) * [Track Events](/docs/track-events) * [Proxy Guide](/docs/proxy-guide) * [Google Tag Manager Guide](/docs/guides/google-tag-manager) * [Hide Your Own Traffic](/docs/hiding-own-traffic) * [Filter Traffic](/docs/filter-traffic) # Tracking script (/docs/script) To start tracking user activity on your website, add the Rybbit tracking script to your site's HTML. ## Installation [#installation] The webpage should give you this script tag like this when you first add a website. Place it within the `<head>` section of your website. Include it on every page you wish to track. ```html <script src="https://app.rybbit.io/api/script.js?siteId=YOUR_SITE_ID" async></script> ``` If you are self-hosting Rybbit `app.rybbit.io` will be domain of your Rybbit instance. The site ID is passed in the script URL. The older `data-site-id="YOUR_SITE_ID"` attribute still works, but the URL form is recommended because script optimizers (WP Rocket, Perfmatters, FlyingPress and similar "delay JavaScript" features) rebuild script tags and drop `data-*` attributes, while leaving the URL intact. <Callout type="info"> **Bypass Ad Blockers**: Some ad blockers may block analytics scripts regardless of privacy compliance. To improve tracking accuracy, you can proxy the tracking script through your own domain. See our [Proxy Guide](/docs/proxy-guide) for setup instructions for Next.js, Nginx, Caddy, and more. </Callout> ## Configuration [#configuration] ### Site Settings Configuration [#site-settings-configuration] Most tracking options are configured through your site settings in the Rybbit dashboard. The script automatically fetches these settings when it loads: * **Automatic Initial Pageview**: Track the first pageview when the script loads * **SPA Navigation**: Automatically track navigation in single-page applications * **URL Parameters**: Include or exclude query strings from tracked URLs * **Outbound Links**: Track clicks to external websites * **Error Tracking**: Capture JavaScript errors and unhandled promise rejections * **Session Replay**: Record user interactions for debugging and UX analysis * **Web Vitals**: Collect Core Web Vitals performance metrics ### Script Attributes [#script-attributes] These are the attributes you can pass to the script tag. | Attribute | Type | Default | Description | | :------------------- | :------- | :------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data-skip-patterns` | `string` | `[]` | A JSON string array of URL path patterns to ignore. Pageviews matching these patterns won't be tracked. Supports two types of wildcards: `*` (matches within a segment) and `**` (matches across segments), plus regex patterns prefixed with `re:`. | | `data-mask-patterns` | `string` | `[]` | A JSON string array of URL path patterns to mask for privacy. Pageviews matching these patterns will be tracked, but the actual URL path will be replaced with the pattern itself in analytics data. Supports the same wildcards and `re:` regex patterns as `data-skip-patterns`. | | `data-debounce` | `string` | `"500"` | The delay (in milliseconds) before tracking a pageview after URL changes via the History API (`pushState`, `replaceState`). Set to `0` to disable debouncing. | | `data-tag` | `string` | `""` | A label applied to all events from this script instance. Useful for A/B testing, deploy versioning, or cohort segmentation. Max 256 characters. See [Tagging](/docs/tagging). | {/* ### Feature Flags The tracking script automatically evaluates client and both-runtime feature flags when it loads. Read evaluated flags from `window.rybbit` after the script is ready: ```javascript window.rybbit.onReady((rybbit) => { const enabled = rybbit.flag("new_checkout", false); const config = rybbit.flagPayload("checkout_config", {}); }); ``` Available methods: | Method | Description | | --- | --- | | `flag(key, fallback)` | Returns a boolean, variant key, or fallback value. Also records a feature flag exposure once per flag value/version. | | `flagPayload(key, fallback)` | Returns the payload for a flag, variant, or remote config. | | `flags()` | Returns all evaluated flag values. | | `flagPayloads()` | Returns all available flag payloads. | See [Feature Flags](/docs/feature-flags) for targeting rules, remote config, and server-side evaluation. */} ### Session Replay Attributes [#session-replay-attributes] These attributes configure rrweb session replay behavior. They only apply when session replay is enabled in your site settings. | Attribute | Type | Default | Description | | :-------------------------------- | :------------ | :------------------------------- | :---------------------------------------------------------------------------------------------------------------------------- | | `data-replay-mask-text-selectors` | `string` | `[]` | A JSON string array of CSS selectors for elements whose text content should be masked. Example: `'[".user-name", "#email"]'`. | | `data-replay-block-class` | `string` | `"rr-block"` | CSS class name for elements to exclude entirely from recording. Elements with this class won't appear in replays. | | `data-replay-block-selector` | `string` | `null` | CSS selector for elements to exclude from recording. Example: `".sensitive-content, #private-section"`. | | `data-replay-ignore-class` | `string` | `"rr-ignore"` | CSS class name for elements whose input events should be ignored. The element appears but interactions aren't recorded. | | `data-replay-ignore-selector` | `string` | `null` | CSS selector for elements whose input events should be ignored. Example: `"input[name='credit-card']"`. | | `data-replay-mask-text-class` | `string` | `"rr-mask"` | CSS class name for elements whose text content should be masked (shown as asterisks). | | `data-replay-mask-all-inputs` | `boolean` | `true` | When true, all input values are masked as asterisks for privacy. Set to `"false"` to disable. | | `data-replay-mask-input-options` | `JSON object` | `{"password":true,"email":true}` | Specify which input types to mask. Example: `'{"password":true,"email":true,"tel":true}'`. | | `data-replay-collect-fonts` | `boolean` | `true` | When true, website fonts are collected for accurate replay rendering. Set to `"false"` to reduce data size. | | `data-replay-sampling` | `JSON object` | See below | Configure event sampling to reduce data volume. See sampling options below. | | `data-replay-slim-dom-options` | `JSON object` | See below | Configure which DOM elements to exclude from recording. See slimDOM options below. | ### Pattern Matching Details [#pattern-matching-details] For `data-skip-patterns` and `data-mask-patterns`, two types of wildcards are supported: * `*` - Matches any characters within a single path segment (doesn't match across `/`) * `**` - Matches any characters across multiple path segments (includes `/`) Examples: * `/admin/*` matches `/admin/dashboard` but not `/admin/users/list` * `/admin/**` matches both `/admin/dashboard` and `/admin/users/list` * `/blog/*/comments` matches `/blog/post-123/comments` but not `/blog/category/post/comments` Regex patterns are also supported when prefixed with `re:`. The regex is used as-is, so add `^` and `$` if you want a full-path match. Examples: * `re:^/users/\\d+$` matches `/users/123` but not `/users/abc` * `re:^/private/.*$` matches anything under `/private/` Example usage: ```html data-skip-patterns='["/admin/**", "/blog/drafts/*", "/preview/**"]' data-mask-patterns='["/users/*/settings", "/accounts/**", "/orders/*/details"]' ``` Regex usage: ```html data-skip-patterns='["re:^/private/.*$"]' data-mask-patterns='["re:^/users/\\d+/settings$"]' ``` **Example with debugging overrides:** ```html <script src="https://app.rybbit.io/api/script.js" defer data-site-id="456" data-skip-patterns='["/admin/**", "/preview/*"]' data-mask-patterns='["/users/*/profile", "/orders/**"]' data-replay-mask-text-selectors='[".user-name", "#email"]' data-debounce="300" ></script> ``` ## Session Replay Configuration [#session-replay-configuration] When session replay is enabled in your site settings, you can fine-tune the recording behavior using the attributes above. This section covers the more complex configuration options. ### Privacy Controls [#privacy-controls] Protect sensitive user information using these options: | Option | Best For | | ---------------------- | ------------------------------------------------------------------ | | `data-replay-block-*` | Completely hide elements (e.g., admin panels, modals with PII) | | `data-replay-ignore-*` | Show elements but don't record interactions (e.g., form fields) | | `data-replay-mask-*` | Show element structure but hide text content (e.g., names, emails) | **Example - Comprehensive privacy setup:** ```html <script src="https://app.rybbit.io/api/script.js" defer data-site-id="123" data-replay-block-selector=".admin-panel, #payment-modal" data-replay-ignore-selector="input[name='ssn'], input[name='credit-card']" data-replay-mask-text-selectors='[".user-name", "#email", "[data-pii]"]' data-replay-mask-input-options='{"password":true,"email":true,"tel":true}' ></script> ``` ### Sampling Configuration [#sampling-configuration] The `data-replay-sampling` attribute controls how frequently events are recorded. Lower sampling rates reduce data volume but may miss some interactions. **Default sampling configuration:** ```json { "mousemove": false, "mouseInteraction": { "MouseUp": false, "MouseDown": false, "Click": true, "ContextMenu": false, "DblClick": true, "Focus": true, "Blur": true, "TouchStart": false, "TouchEnd": false }, "scroll": 500, "input": "last", "media": 800 } ``` **Sampling options:** * `mousemove`: `false` to disable, or number (ms) for sampling interval * `scroll`: Sampling interval in milliseconds (e.g., `500` = sample every 500ms) * `input`: `"last"` to only record final value, or `"all"` to record every keystroke * `media`: Sampling interval for media events in milliseconds **Example - Enable mouse tracking for UX analysis:** ```html data-replay-sampling='{"mousemove":100,"scroll":150}' ``` ### SlimDOM Options [#slimdom-options] The `data-replay-slim-dom-options` attribute controls which DOM elements are excluded from recording to reduce data size. **Default slimDOM configuration:** ```json { "script": false, "comment": true, "headFavicon": true, "headWhitespace": true, "headMetaDescKeywords": true, "headMetaSocial": true, "headMetaRobots": true, "headMetaHttpEquiv": true, "headMetaAuthorship": true, "headMetaVerification": true } ``` Set to `true` to enable all slimDOM options, or `false` to disable all: ```html data-replay-slim-dom-options="true" ``` ### Complete Example [#complete-example] ```html <script src="https://app.rybbit.io/api/script.js" defer data-site-id="456" data-skip-patterns='["/admin/**", "/preview/*"]' data-mask-patterns='["/users/*/profile", "/orders/**"]' data-debounce="300" data-replay-block-selector=".sensitive-content" data-replay-mask-text-selectors='[".user-name", "#email"]' data-replay-mask-all-inputs="true" data-replay-collect-fonts="false" data-replay-sampling='{"mousemove":false,"scroll":500}' ></script> ``` # Segments (/docs/segments) A segment is a named, reusable set of [filters](/docs/feature-guides/main-tab#filters). Instead of rebuilding "mobile visitors from Germany who came from search" every time, save it once and apply it to the main dashboard, pages, sessions, funnels, journeys, retention, and exports with one click. Segments live inside the **Filter** button. Nothing else changes in the dashboard: applying a segment expands its filters into the same filter bar every report already reads. ## Creating a segment [#creating-a-segment] Open the **Filter** button and switch to the **Segments** tab. There are two ways to create one: <Steps> <Step> ### From scratch [#from-scratch] Click **New segment**. The dialog has its own filter editor, so you can build the definition without applying anything to the dashboard first. </Step> <Step> ### From the current filters [#from-the-current-filters] Once the dashboard has filters applied, the Segments tab offers **Save current filters as segment**. The dialog opens with those filters and a suggested name. </Step> </Steps> Every segment has: * **Name** — what the group means to you, such as "Paid traffic" or "Logged-in users" * **Filters** — at least one. Every filter must match; multiple values inside one filter are alternatives * **Available on** — this site only, or every site in the organization (organization-wide segments can only be created by admins and owners) * **Description** — optional notes for your team * **Show on the public dashboard and private links** — off by default <Callout type="info"> Filters in a segment are validated against the same schema as the filter bar, so a segment can never save something the dashboard could not query. </Callout> ## Applying a segment [#applying-a-segment] In the **Segments** tab, click a segment to apply it. It appears in the filter bar as a single chip named after the segment. Click the chip to see the filters it contains; click the **×** to remove all of them at once. Ad-hoc filters work alongside an applied segment. Add a filter the usual way and it is ANDed with the segment. If one of the segment's own filters is changed on the dashboard, the chip reads **(edited)** so you know the row no longer matches the saved definition; the segment itself is unchanged. Segments are part of the URL, so a dashboard link with a segment applied opens the same view for whoever you share it with. ## Editing and deleting [#editing-and-deleting] Use the **⋯** menu on a segment in the Segments tab, or **Edit segment** from the applied chip. Changes apply everywhere the segment is used. Deleting a segment removes it for everyone; links that already expanded its filters keep working. ## Permissions [#permissions] | Role | Read and apply | Create | Edit and delete | | --------------------------------------- | ------------------------------------------------ | -------------------------- | --------------------- | | Owner or admin | All segments | Site and organization-wide | Any segment | | Member | All segments | Site segments | Segments they created | | Public dashboard or private-link viewer | Segments marked as shown on the public dashboard | — | — | ## API and MCP [#api-and-mcp] Every analytics endpoint accepts a `segment_id` query parameter that is expanded server-side, ANDed with any `filters` you also pass. Segments themselves are managed through the [Segments endpoints](/docs/api/segments/list). The [MCP server](/docs/mcp) exposes `list_segments` and `apply_segment`. # Self-hosted vs Cloud (/docs/self-host-vs-cloud) Rybbit offers two deployment options: a fully-managed [cloud version](https://rybbit.com) and a [self-hosted](/docs/self-hosting) solution. This guide will help you choose the right option for your needs. ## Quick Comparison [#quick-comparison] | Feature | Cloud | Self-Hosted | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | **Setup Time** | Minutes | 30 - 120 minutes | | **Maintenance Required** | None | Regular updates | | **Cookieless & Privacy friendly** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | | **Web Analytics** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | | **API** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | | **Globe** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | | **Session Replays** | <span className="inline-flex items-center gap-1"><Check className="w-5 h-5" color="green" strokeWidth="3" /> (On pro tier)</span> | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | | **Funnels** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | | **Goals** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | | **Sessions** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | | **Journeys** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | | **Users** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | | **Retention** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | | **Error tracking** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | | **Organizations/Teams** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | | **Pages View** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <Minus className="w-5 h-5 inline" color="gray" strokeWidth="3" /> | | **Web Vitals** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | | **Email reports** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <Minus className="w-5 h-5 inline" color="gray" strokeWidth="3" /> | | **Google Search Console integration** | <Check className="w-5 h-5 inline" color="green" strokeWidth="3" /> | <span className="inline-flex items-center gap-1"><Check className="w-5 h-5" color="green" strokeWidth="3" /> (Requires Google OAuth env vars)</span> | # Quick start (/docs/self-hosting) This guide will walk you through setting up your own instance of Rybbit using our setup script. If you want to manually setup Rybbit using the Docker Compose file, check out our [Manual Docker Compose Setup](/docs/self-hosting-guides/self-hosting-manual) guide. ## Prerequisites [#prerequisites] Before you begin, ensure you have the following: * **A VPS:** Try to get one with at least 2GB of RAM. * **A Domain Name:** You'll need a domain or subdomain (e.g., `tracking.yourdomain.com`) pointed to your VPS's IP address. HTTPS is required because browsers block tracking scripts served over insecure HTTP. <Callout type="info"> This guide has been tested on Ubuntu 24 LTS (x86\_64). If your server is ARM, it must be at least ARMv8.2-A for Clickhouse support. </Callout> ## Setup Steps [#setup-steps] <Steps> <Step> ### Point Your Domain to Your VPS [#point-your-domain-to-your-vps] Configure your domain's DNS settings to point to your VPS's public IP address. This usually involves: 1. Finding your VPS's public IPv4 address (from your hosting provider, e.g., Hetzner). 2. Logging into your domain registrar or DNS provider (e.g., GoDaddy, Namecheap, Cloudflare). 3. Adding an `A` record: * **Host/Name:** Your desired subdomain (e.g., `tracking`) or `@` for the root domain. * **Value:** Your VPS's IPv4 address. * **Proxy Status:** When using Cloudflare, either: * Set to "DNS only" (recommended for most setups) * Or if keeping the proxy enabled, set SSL/TLS encryption mode to "Full" or "Full (strict)" in your Cloudflare dashboard under SSL/TLS settings <Callout type="warning"> If you keep Cloudflare's proxy enabled and your domain has other services not running on HTTPS, using Full or Full (strict) mode might cause issues with those services. In such cases, either use "DNS only" mode or ensure all your services support HTTPS. </Callout> DNS changes might take some time to propagate globally. Use [DNS Checker](https://dnschecker.org/) to verify. </Step> <Step> ### Install Docker Engine [#install-docker-engine] Connect to your VPS via SSH. Follow the official Docker Engine installation instructions for your Linux distribution: [https://docs.docker.com/engine/install/](https://docs.docker.com/engine/install/) </Step> <Step> ### Clone the Rybbit Repository [#clone-the-rybbit-repository] Clone the project repository from GitHub (Git is usually pre-installed on most server distributions): ```bash git clone https://github.com/rybbit-io/rybbit.git cd rybbit ``` </Step> <Step> ### Run the Setup Script [#run-the-setup-script] The repository includes a setup script that configures the necessary environment variables (including generating a secure secret) and starts the application using Docker Compose. <Callout type="warning"> Important: Make all scripts executable before proceeding! ```bash chmod +x *.sh ``` </Callout> Run the setup script, replacing `your.domain.name` with the domain or subdomain you configured in the prerequisites: ```bash ./setup.sh your.domain.name ``` If you want to enable 3D map visualizations, you can add the `--mapbox-token` flag: ```bash ./setup.sh your.domain.name --mapbox-token YOUR_MAPBOX_TOKEN ``` The script will create a `.env` file and then build and start the containers. This might take a few minutes the first time. <Callout type="info"> By default, we assume you are on a blank VPS and automatically setup a Caddy webserver. If you want to use your own webserver or need more control over the setup, check out: * [Manual Docker Compose Setup](/docs/self-hosting-guides/self-hosting-manual) - For users with existing reverse proxies or deployment tools * [Advanced Self-Hosting Guide](/docs/self-hosting-guides/self-hosting-advanced) - For custom configurations and Nginx setup </Callout> </Step> <Step> ### Sign Up [#sign-up] Once the services are running and DNS has propagated, Caddy (the webserver) will automatically obtain an SSL certificate for your domain. Open your browser and navigate to `https://your.domain.name/signup` (using the domain you provided to the setup script). Create your admin account. You can then log in and start adding your websites! </Step> </Steps> If you run into any issues or need help, feel free to join our Discord community! # Site settings (/docs/site-settings) Access your site settings by clicking the settings icon in your site's Rybbit dashboard. <img alt="site settings" src="__img0" /> ## Privacy & Security [#privacy--security] ### Public Analytics [#public-analytics] Make your analytics publicly accessible by enabling the "Public Analytics" toggle. * External viewers have read-only access to your analytics data * Viewers cannot edit settings, reports, funnels, or goals * Your other websites are not affected ### User ID Salting [#user-id-salting] Rybbit stores user IDs as a hashed combination of IP + user agent. This allows identifying users across sessions, but may be considered storing personally identifiable data under GDPR. Enable "User ID Salting" to have Rybbit forget about unique users every day. If a user visits 10 times in one day, they count as one user with 10 sessions. But if they visit once a day for 10 days, they appear as a new user each day. **Downsides of enabling user ID salting:** * User session history beyond 24 hours is not available * Unique user counts over periods longer than 24 hours will be inflated * The user retention page becomes effectively useless ### Block Bot Traffic [#block-bot-traffic] Enable "Block Bot Traffic" to prevent detected bot traffic from being tracked in your normal analytics. See [Bot detection](/docs/bot-detection) for how Rybbit identifies and stores bot traffic. ### Track IP Address [#track-ip-address] Enable "Track IP Address" to store the IP address of visitors. <Callout type="warn"> This is not GDPR compliant. Only enable if you have a legitimate need and proper consent mechanisms in place. </Callout> ## Analytics Features [#analytics-features] ### Session Replay <span className="text-green-500 text-sm">(Pro)</span> [#session-replay-pro] Record and replay user sessions to understand user behavior. This feature allows you to see exactly how users interact with your site. ### Web Vitals <span className="text-green-500 text-sm">(Standard)</span> [#web-vitals-standard] Track Core Web Vitals metrics including: * **LCP** (Largest Contentful Paint) * **CLS** (Cumulative Layout Shift) * **INP** (Interaction to Next Paint) * **FCP** (First Contentful Paint) * **TTFB** (Time to First Byte) ### Error Tracking <span className="text-green-500 text-sm">(Standard)</span> [#error-tracking-standard] Capture JavaScript errors and exceptions from your site to identify and fix issues affecting your users. ### Track Outbound Links [#track-outbound-links] Track when users click on external links leaving your site. Enabled by default. ### Track URL Parameters [#track-url-parameters] Include query string parameters in page tracking. Enabled by default. Disable if you want cleaner URLs in your analytics or need to exclude sensitive parameters. ### Track Initial Page View [#track-initial-page-view] Automatically track the first page view when the tracking script loads. Enabled by default. ### Track SPA Navigation [#track-spa-navigation] Automatically track navigation in single-page applications (SPAs). Enabled by default. This detects URL changes via the History API and tracks them as page views. ## Traffic Filtering [#traffic-filtering] ### IP Exclusions [#ip-exclusions] Block specific IP addresses or ranges from being tracked. Supports: * Individual IP addresses (e.g., `192.168.1.1`) * CIDR notation (e.g., `192.168.1.0/24`) * IPv4 ranges (e.g., `192.168.1.1-192.168.1.255`) ### Country Exclusions [#country-exclusions] Block all traffic from specific countries using ISO country codes (e.g., `US`, `DE`, `FR`). ### Path Exclusions [#path-exclusions] Exclude traffic to specific pages. Use `*` as a wildcard (e.g., `/admin/*` or `/preview`). Matching is case-insensitive. Up to 100 path exclusions are allowed. ### Hostname Exclusions [#hostname-exclusions] Exclude traffic from specific hostnames such as staging or preview domains. Use `*` as a wildcard (e.g., `localhost` or `*.vercel.app`). Matching is case-insensitive. Up to 100 hostname exclusions are allowed. ### User Agent Exclusions [#user-agent-exclusions] Exclude traffic from user agents containing any of these terms. Matching is a case-insensitive substring (e.g., `HeadlessChrome`). Up to 100 user agent exclusions are allowed. All of these exclusions work at the server level, ensuring blocked traffic never reaches your analytics database. ## Embeds [#embeds] Show Rybbit data on another site with an embeddable iframe. * **Widget Embeds** show a compact live visitor widget. Enable them from the **Widget Embeds** tab. This is independent of Public Analytics and only exposes the data the widget displays. See the [Widget Embeds guide](/docs/embeds/widget) for details. * **Dashboard Embed** shows the analytics dashboard in an iframe. Configure it from the **Dashboard Embed** tab. It uses a generated private link, not Public Analytics. See the [Dashboard Embed guide](/docs/embeds/dashboard) for details. ## Google Search Console <span className="text-green-500 text-sm">(Cloud)</span> [#google-search-console-cloud] Connect your Google Search Console account to see search performance data alongside your analytics. ## Change Domain [#change-domain] Update the domain for your site. Changing domains does not affect existing data - all historical analytics are preserved. ## Delete Site [#delete-site] Permanently delete a site and all associated data. <Callout type="warn"> This action cannot be undone. All analytics data, reports, funnels, and goals will be permanently deleted. </Callout> # Tagging (/docs/tagging) Tags let you label all events from a script instance with a string identifier. This is useful for A/B testing, deploy labeling, cohort segmentation, and any scenario where you want to slice your analytics by a custom dimension that applies to the entire page. If you need Rybbit to choose a value at runtime, gradually roll out a feature, or serve remote config, use [Feature Flags](/docs/feature-flags) instead. ## How It Works [#how-it-works] Add a `data-tag` attribute to your Rybbit script tag. Every event (pageviews, custom events, outbound clicks, etc.) sent from that page will include the tag value, which is stored in ClickHouse and available as a filter in the dashboard. ```html <script src="https://app.rybbit.io/api/script.js" async data-site-id="YOUR_SITE_ID" data-tag="variant-a" ></script> ``` The tag is a simple string with a maximum length of 256 characters. If `data-tag` is not set or is empty, no tag is stored. ## Use Cases [#use-cases] ### A/B Testing [#ab-testing] Serve different script tags to different user groups and compare behavior: ```html <!-- Variant A --> <script src="https://app.rybbit.io/api/script.js" async data-site-id="1" data-tag="homepage-v2-a"></script> <!-- Variant B --> <script src="https://app.rybbit.io/api/script.js" async data-site-id="1" data-tag="homepage-v2-b"></script> ``` Then filter by tag in the dashboard to compare metrics between variants. ### Deploy / Release Labeling [#deploy--release-labeling] Tag events with the current deploy version to correlate releases with changes in user behavior or errors: ```html <script src="https://app.rybbit.io/api/script.js" async data-site-id="1" data-tag="v2.4.1" ></script> ``` ### Cohort Segmentation [#cohort-segmentation] Label traffic from different sources, campaigns, or user segments: ```html <script src="https://app.rybbit.io/api/script.js" async data-site-id="1" data-tag="beta-users" ></script> ``` ## Filtering by Tag [#filtering-by-tag] Once events with tags are flowing in, you can filter by tag anywhere in the dashboard: 1. Click the **filter** button in the dashboard header 2. Select **Tag** from the filter parameter dropdown 3. Choose an operator (is, is not, contains, etc.) 4. Enter the tag value The tag filter works on all dashboard pages including overview, pages, referrers, events, users, funnels, and goals. ## API Usage [#api-usage] When sending events via the [HTTP API](/docs/api/sending-events), include `tag` in the request body: ```json { "site_id": "YOUR_SITE_ID", "type": "pageview", "hostname": "example.com", "pathname": "/", "tag": "variant-a" } ``` ## Important Notes [#important-notes] * Each script instance can only have one tag. If you need multiple dimensions, consider using [custom event properties](/docs/track-events) instead. * Tags are set at the script level, not per-event. All events from a page load share the same tag. * The tag value is stored as-is (no hashing or transformation). * Empty or missing `data-tag` attributes result in no tag being stored. **Related Documentation:** * [Tracking Script](/docs/script) * [Track Events](/docs/track-events) * [Identify Users](/docs/identify-users) # Teams (/docs/teams) Teams let you group sites together and control which organization members can access them. This is useful for organizations managing multiple properties where different people are responsible for different sites. Access teams by clicking on "Teams" or going to [app.rybbit.io/settings/teams](https://app.rybbit.io/settings/teams). <img alt="teams" src="__img0" /> ## How Teams Work [#how-teams-work] A team has three things: * **A name** — identifies the team (e.g. "Marketing Sites", "Client Projects") * **Members** — organization members assigned to the team * **Sites** — the sites that belong to the team When a member is assigned to a team, they can access all sites within that team. Admins and owners always have access to all sites regardless of team assignments. <Callout type="info"> Teams are an organizational tool for admins and owners. Members with the "Member" role can only see sites that belong to their assigned teams (unless they have unrestricted access). </Callout> ## Creating a Team [#creating-a-team] 1. Go to **Settings > Teams** 2. Click the **Create Team** button 3. Enter a team name 4. Select which members should belong to the team 5. Select which sites the team should have access to 6. Click **Create Team** You can also create a team directly from the team filter on the home page. ## Managing Teams [#managing-teams] ### Editing a Team [#editing-a-team] Click the edit icon on any team card to update its name, members, or site assignments. ### Deleting a Team [#deleting-a-team] Click the delete icon on a team card. Sites assigned to the team will become unassigned and visible to all members. <Callout type="warn"> Deleting a team removes all member-to-site associations through that team. Members who only had access via the deleted team will lose access to those sites. </Callout> ## Filtering by Team [#filtering-by-team] On the home page, use the team selector in the toolbar to filter the site list by team. The options are: * **All Teams** — show all sites * **No Team** — show only sites not assigned to any team * **Specific team** — show only sites belonging to that team ## Assigning Teams During Invitation [#assigning-teams-during-invitation] When [inviting a new member](/docs/inviting-users), you can optionally assign them to a team. When the member accepts the invitation, they will automatically be added to the selected team. ## Permissions [#permissions] | Role | Can create teams | Can edit/delete teams | Can view team-restricted sites | | ---------- | ---------------- | --------------------- | ---------------------------------- | | **Owner** | Yes | Yes | All sites | | **Admin** | Yes | Yes | All sites | | **Member** | No | No | Only sites in their assigned teams | # Track Events (/docs/track-events) The script exposes a global object `window.rybbit` with functions for manual tracking of events, as well as the ability to track events using data attributes on HTML elements. ## Using Data Attributes [#using-data-attributes] You can track custom events by adding data attributes to any HTML element: ```html <!-- Basic event tracking --> <button data-rybbit-event="signup_clicked">Sign Up</button> <!-- Event with custom properties --> <button data-rybbit-event="purchase_click" data-rybbit-prop-product="premium" data-rybbit-prop-price="49.99" > Buy Now </button> ``` This approach requires no JavaScript and works on any clickable element. ## Available Functions [#available-functions] ### `window.rybbit.event(eventName, properties)` [#windowrybbiteventeventname-properties] Tracks a custom event with optional properties. * `eventName` (String): The name of the custom event (max 256 characters). * `properties` (Object, Optional): An object containing custom data (max 2KB characters). Only strings numbers are supported as values. ```javascript // Track a simple custom event window.rybbit.event("Signup Button Clicked"); // Track a custom event with properties window.rybbit.event("Item Added To Cart", { itemId: "PROD123", price: 49.99, category: "Clothing" }); ``` ### `window.rybbit.pageview()` [#windowrybbitpageview] Tracks a pageview. Useful when automatic SPA navigation tracking is disabled in your site settings, or when you need to manually trigger a pageview. ```javascript // Track a pageview window.rybbit.pageview(); ``` ## TypeScript Support [#typescript-support] Put this as `rybbit.d.ts` anywhere in your project to avoid having to do `(window as any).rybbit.event()` everywhere. ```typescript interface Rybbit { /** * Tracks a page view */ pageview: () => void; /** * Tracks a custom event * @param name Name of the event * @param properties Optional properties for the event */ event: (name: string, properties?: Record<string, any>) => void; /** * Sets a custom user ID for tracking logged-in users * @param userId The user ID to set (will be stored in localStorage) * @param traits Optional user metadata (email, name, custom fields) */ identify: (userId: string, traits?: Record<string, unknown>) => void; /** * Updates traits for the currently identified user * @param traits User metadata to merge with existing traits */ setTraits: (traits: Record<string, unknown>) => void; /** * Clears the stored user ID */ clearUserId: () => void; /** * Gets the currently set user ID * @returns The current user ID or null if not set */ getUserId: () => string | null; /** * Manually tracks outbound link clicks * @param url The URL of the outbound link * @param text Optional text content of the link * @param target Optional target attribute of the link */ trackOutbound: (url: string, text?: string, target?: string) => void; /** * Evaluates a feature flag and returns its value. Reading a flag records a * feature flag exposure once per flag value/version. * @param key The flag key * @param fallback Value returned before flags resolve or if the flag is inactive */ flag: <T = unknown>(key: string, fallback?: T) => T; /** * Returns the payload for a feature flag or its selected variant. * Use this for remote config and multivariate payloads. * @param key The flag key * @param fallback Value returned before flags resolve or if the flag is inactive */ flagPayload: <T = unknown>(key: string, fallback?: T) => T; /** * Returns all evaluated flag values keyed by flag key */ flags: () => Record<string, unknown>; /** * Returns all available flag payloads keyed by flag key */ flagPayloads: () => Record<string, unknown>; /** * Runs a callback once the tracking script and feature flags are ready. * Use this before reading flags during page initialization. * @param callback Receives the ready rybbit instance */ onReady: (callback: (rybbit: Rybbit) => void) => void; } declare global { interface Window { rybbit: Rybbit; } } export {}; ``` ## Server-Side Tracking [#server-side-tracking] For tracking events from server-side applications, mobile apps, or any platform outside the browser, you can use the HTTP API. This is useful for: * Server-side event tracking (Node.js, Python, Go, etc.) * Mobile app analytics (iOS, Android, Flutter) * Backend integrations and webhooks * Scenarios where the JavaScript snippet can't be used See the [API documentation](/docs/api/sending-events) for detailed examples in multiple languages including authentication, parameters, and rate limiting information. *** **Related Documentation:** * [Identify Users](/docs/identify-users) * [Behavior Analytics - Events Tab](/docs/behavior-analytics/events) * [Behavior Analytics - Users Tab](/docs/behavior-analytics/users) * [Behavior Analytics - Sessions Tab](/docs/behavior-analytics/sessions) * [Product Analytics - Retention Guide](/docs/product-analytics/retention_guide) * [Product Analytics - Funnels](/docs/product-analytics/funnels_guide) * [API - Sending Events](/docs/api/sending-events) * [Blog: Churn Rate vs Retention Rate](/blog/churn-rate-vs-retention-rate) * [Web SDK Reference](/docs/sdks/web) # Troubleshooting (/docs/troubleshooting) ## Full Docker Cleanup [#full-docker-cleanup] If you encounter issues with Docker containers, images, or volumes and need to start fresh, you can perform a complete Docker cleanup. This will remove all containers, images, volumes, and custom networks. <Callout type="warn"> **Warning**: This will remove ALL Docker containers, images, and volumes on your system, not just Rybbit-related ones. Make sure you don't have other important Docker containers running. </Callout> ### Steps for Complete Docker Cleanup [#steps-for-complete-docker-cleanup] <Steps> <Step> ### Remove your environment file [#remove-your-environment-file] ```bash rm .env ``` </Step> <Step> ### Stop all running containers: [#stop-all-running-containers] ```bash docker stop $(docker ps -q) ``` </Step> <Step> ### Remove all containers (both running and stopped): [#remove-all-containers-both-running-and-stopped] ```bash docker rm -f $(docker ps -aq) ``` </Step> <Step> ### Remove all images: [#remove-all-images] ```bash docker rmi -f $(docker images -q) ``` </Step> <Step> ### Remove all volumes: [#remove-all-volumes] ```bash docker volume rm -f $(docker volume ls -q) ``` </Step> <Step> ### Remove all custom networks (preserves default networks): [#remove-all-custom-networks-preserves-default-networks] ```bash docker network rm $(docker network ls | grep -v "bridge\|host\|none" | awk '{print $1}') ``` </Step> </Steps> ### Alternative: Rybbit-Specific Cleanup [#alternative-rybbit-specific-cleanup] If you only want to clean up Rybbit-related Docker resources without affecting other containers, use these commands instead: ```bash # Stop and remove Rybbit containers docker-compose down # Remove Rybbit images docker-compose down --rmi all # Remove Rybbit volumes (this will delete your data!) docker-compose down --volumes # Complete Rybbit cleanup docker-compose down --rmi all --volumes --remove-orphans ``` ### After Cleanup [#after-cleanup] Once you've completed the cleanup, you can restart Rybbit by following the setup instructions: ```bash # Copy environment file cp .env.example .env # Edit .env with your configuration # Then start the services docker-compose up -d ``` ## ClickHouse keeps crashing on a Proxmox VM [#clickhouse-keeps-crashing-on-a-proxmox-vm] ClickHouse requires the SSE 4.2 instruction set. Proxmox VMs default to a generic CPU type (like `kvm64` or `x86-64-v2-AES`) that doesn't expose all of the host CPU's instruction sets to the VM, which causes ClickHouse to fail with `Illegal instruction` errors or get stuck in a restart loop. To fix this, set the VM's CPU type to `host` so it passes through the physical CPU directly: <Steps> <Step> ### Open the VM's hardware settings [#open-the-vms-hardware-settings] In the Proxmox web UI, select your VM, then go to **Hardware** → **Processors** → **Edit**. </Step> <Step> ### Set the CPU type to `host` [#set-the-cpu-type-to-host] Change the **Type** dropdown to `host` and click **OK**. </Step> <Step> ### Fully stop and start the VM [#fully-stop-and-start-the-vm] A reboot from inside the guest is not enough — do a full **Stop** and then **Start** from Proxmox for the CPU change to take effect. </Step> </Steps> <Callout type="info"> The `host` CPU type can prevent live migration between Proxmox nodes with different CPU models. If you need migration, any CPU type that includes SSE 4.2 (e.g. `x86-64-v2-AES` on a host that supports it, or newer) may also work — but `host` is the most reliable option. </Callout> ## I ran out of disk space [#i-ran-out-of-disk-space] Regular web analytics events take up almost no space in Clickhouse, so it is very unlikely that you ran out of disk space from from this. There are two main reasons why you might have run out of disk space: ### 1. You are using session replay [#1-you-are-using-session-replay] On self-hosted instances where we don't have access to S3 compatible object storage, session replays are stored in Clickhouse. Each session replay is at least 1000x the data footprint of the web analytics events. So it's very easy to run out of disk space. We have a 30 day retention policy for session replays events, so if you turn off session replay, your disk space will slowly come back. If you need to immediately free up space, you can truncate the session replays table: ```bash docker exec -it clickhouse clickhouse-client --database analytics TRUNCATE TABLE session_replay_events; TRUNCATE TABLE session_replay_metadata; ``` ### 2. You didn't mount the Clickhouse log suppression configs [#2-you-didnt-mount-the-clickhouse-log-suppression-configs] In our [docker-compose.yml](https://github.com/rybbit-io/rybbit/blob/master/docker-compose.yml), we have a config for Clickhouse that suppresses logs. Clickhouse stores a ton of logs in the log tables by default. This will quickly fill up your disk space if left unchecked. If you follow the standard installation process, the configs are already mounted for you. If you rolled your own Docker setup, you need to mount the configs manually. # Migration to v1.0 (/docs/v1-migration) This guide covers the breaking changes and migration steps when upgrading to Rybbit v1.0. ## API Path Changes [#api-path-changes] In v1.0, we've simplified the reverse proxy configuration by having the backend expect requests with the `/api/` prefix. This eliminates the need for custom path rewriting in reverse proxy configurations. ### If You're Using the Built-in Caddy Server [#if-youre-using-the-built-in-caddy-server] **No action required.** The built-in Caddy server configuration has been automatically updated and will work without any changes on your part. ### If You're Using a Custom Reverse Proxy [#if-youre-using-a-custom-reverse-proxy] **You need to remove any custom API path rewriting** from your reverse proxy configuration. <Callout type="warn"> **Important:** If you were using a custom reverse proxy (Nginx, Apache, etc.) with path rewriting rules for the `/api/` endpoint, you must remove these rules when upgrading to v1.0. </Callout> #### What to Remove [#what-to-remove] Remove any configuration that strips or rewrites the `/api/` prefix when forwarding requests to the backend. Common examples include: **Nginx - Remove these patterns:** ```nginx # ❌ Remove this type of configuration location /api/ { rewrite ^/api(/.*)$ $1 break; # Remove this line proxy_pass http://localhost:3001/; # Change trailing slash } ``` **Update to:** ```nginx # ✅ Correct v1.0 configuration location /api/ { proxy_pass http://localhost:3001; # No trailing slash, no rewrite proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } ``` **Apache - Remove these patterns:** ```apache # ❌ Remove this type of configuration ProxyPass /api/ http://localhost:3001/ ProxyPassReverse /api/ http://localhost:3001/ ``` **Update to:** ```apache # ✅ Correct v1.0 configuration ProxyPass /api/ http://localhost:3001/api/ ProxyPassReverse /api/ http://localhost:3001/api/ ``` **Docker Compose with custom location rules:** ```yaml # ❌ Remove rewrite rules like this location /api/ { rewrite ^/api(/.*)$ $1 break; proxy_pass http://rybbit_backend:3001; } ``` **Update to:** ```yaml # ✅ Correct v1.0 configuration location /api/ { proxy_pass http://rybbit_backend:3001; } ``` #### Why This Change? [#why-this-change] This change simplifies the reverse proxy setup and eliminates a common source of configuration errors. The backend now handles the `/api/` prefix internally, making the setup more straightforward and consistent across different proxy solutions. ## Migration Steps [#migration-steps] 1. **Backup your current configuration** before making any changes 2. **Update your reverse proxy configuration** to remove API path rewriting (if applicable) 3. **Update to v1.0** using the update script: ```bash ./update.sh ``` 4. **Verify your setup** by checking that your Rybbit instance is accessible and functioning correctly ## Need Help? [#need-help] If you encounter issues during migration or need assistance with your specific reverse proxy setup, feel free to join our Discord community for support. # Compare Channel Performance (/docs/api/channel-performance) The channel that sends the most visitors is rarely the channel that sends the most *customers*. This guide ranks each acquisition channel by conversion rate so you can see which sources punch above their weight — and which ones are vanity traffic. **Endpoints used:** * [`GET /metric`](/docs/api/overview/metric) — sessions broken down by channel * [`GET /goals`](/docs/api/goals/list) — conversion metrics, scoped with a channel filter <Callout type="info"> The key technique here is the [`filters` parameter](/docs/api/getting-started#filter-parameters). Almost every endpoint accepts it, so you can take any site-wide metric and recompute it for a single segment — here, one channel at a time. </Callout> ## The workflow [#the-workflow] <Steps> <Step> **List your channels** and their session counts with `metric?parameter=channel`. </Step> <Step> **For each channel, fetch your goal's conversion rate** by calling `/goals` with a `channel` filter applied. </Step> <Step> **Rank by conversion rate** to reveal which channels actually drive outcomes. </Step> </Steps> ## Full script [#full-script] This script ranks every channel by the conversion rate of a chosen goal (here, goal `1` — your signup goal). Swap in whichever goal matters to your business. <Tabs items="['JavaScript', 'Python']"> <Tab value="JavaScript"> ```javascript title="channel-performance.js" const API = 'https://app.rybbit.io'; const SITE = '123'; const API_KEY = process.env.RYBBIT_API_KEY; const PRIMARY_GOAL_ID = 1; // the goal you care about const range = { start_date: '2024-01-01', end_date: '2024-01-31', time_zone: 'America/New_York' }; async function get(path, params) { const url = new URL(`${API}/api/sites/${SITE}${path}`); Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); const res = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` } }); if (!res.ok) throw new Error(`${path} failed: ${res.status}`); return res.json(); } // 1. All channels with their session counts async function getChannels() { const { data } = await get('/metric', { parameter: 'channel', limit: 20, ...range }); return data.data; // [{ value: 'Organic Search', count: 5400, ... }] } // 2. Conversion rate for one channel, scoped with a filter async function goalForChannel(channel) { const filters = JSON.stringify([{ parameter: 'channel', type: 'equals', value: [channel] }]); const { data } = await get('/goals', { filters, ...range }); return data.find((g) => g.goalId === PRIMARY_GOAL_ID); } async function rankChannels() { const channels = await getChannels(); const rows = await Promise.all( channels.map(async (c) => { const goal = await goalForChannel(c.value); return { channel: c.value || 'Direct', sessions: c.count, conversions: goal?.total_conversions ?? 0, // conversion_rate comes back as a 0–1 fraction conversionRate: (goal?.conversion_rate ?? 0) * 100, }; }) ); return rows.sort((a, b) => b.conversionRate - a.conversionRate); } rankChannels().then((rows) => { console.log('Channel'.padEnd(18), 'Sessions'.padStart(10), 'Conv.'.padStart(8), 'Rate'.padStart(8)); for (const r of rows) { console.log( r.channel.padEnd(18), r.sessions.toLocaleString().padStart(10), String(r.conversions).padStart(8), `${r.conversionRate.toFixed(2)}%`.padStart(8) ); } }); ``` </Tab> <Tab value="Python"> ```python title="channel_performance.py" import os, json, requests API = "https://app.rybbit.io" SITE = "123" API_KEY = os.environ["RYBBIT_API_KEY"] PRIMARY_GOAL_ID = 1 # the goal you care about RANGE = {"start_date": "2024-01-01", "end_date": "2024-01-31", "time_zone": "America/New_York"} session = requests.Session() session.headers["Authorization"] = f"Bearer {API_KEY}" def get(path, **params): res = session.get(f"{API}/api/sites/{SITE}{path}", params=params) res.raise_for_status() return res.json() def get_channels(): # 1. All channels with their session counts return get("/metric", parameter="channel", limit=20, **RANGE)["data"]["data"] def goal_for_channel(channel): # 2. Conversion rate for one channel, scoped with a filter filters = json.dumps([{"parameter": "channel", "type": "equals", "value": [channel]}]) goals = get("/goals", filters=filters, **RANGE)["data"] return next((g for g in goals if g["goalId"] == PRIMARY_GOAL_ID), None) def rank_channels(): rows = [] for c in get_channels(): goal = goal_for_channel(c["value"]) rows.append({ "channel": c["value"] or "Direct", "sessions": c["count"], "conversions": goal["total_conversions"] if goal else 0, # conversion_rate comes back as a 0–1 fraction "conversion_rate": (goal["conversion_rate"] if goal else 0) * 100, }) return sorted(rows, key=lambda r: r["conversion_rate"], reverse=True) print(f"{'Channel':<18}{'Sessions':>10}{'Conv.':>8}{'Rate':>8}") for r in rank_channels(): print(f"{r['channel']:<18}{r['sessions']:>10,}{r['conversions']:>8}{r['conversion_rate']:>7.2f}%") ``` </Tab> </Tabs> ## Reading the result [#reading-the-result] ```text Channel Sessions Conv. Rate Referral 1,820 310 17.03% Organic Search 5,400 642 11.89% Direct 4,220 451 10.69% Email 980 88 8.98% Paid Search 2,310 142 6.15% Social 6,150 119 1.93% ``` Social sends the **most** traffic (6,150 sessions) but converts the **worst** (1.93%). Referral sends a fraction of the volume but converts nine times better. The takeaway writes itself: the referral partnerships deserve more investment, and the social strategy needs a hard look before more budget goes into it. <Callout type="warn"> Conversion rate alone can mislead on tiny samples — Email looks healthy at 9% but it's only 88 conversions. Always show the raw `sessions` and `conversions` columns next to the rate so a channel with 12 sessions and one fluke conversion doesn't top your list. </Callout> ## Variations [#variations] * **By UTM campaign instead of channel:** swap `parameter: 'channel'` for `parameter: 'utm_campaign'`, and filter on `utm_campaign` in the goals call. Same code, campaign-level granularity. * **Engagement instead of conversion:** call [`/overview`](/docs/api/overview/overview) with the channel filter to compare `bounce_rate` and `session_duration` per channel — useful when you don't have a goal defined yet. * **Full-funnel by channel:** run [`/funnels/analyze`](/docs/api/funnels/analyze) with a channel filter to see *where* in the funnel each channel's visitors fall out, not just the final rate. ## Next steps [#next-steps] * [Find Where Users Drop Off](/docs/api/funnel-dropoff) — once you know your best channels, fix the funnel they pour into. * [Automated Weekly Report](/docs/api/weekly-report) — add a channel league table to a scheduled digest. # Find Where Users Drop Off (/docs/api/funnel-dropoff) A funnel tells you *how many* people fall out at each step. The journeys endpoint tells you *where they went instead*. Putting them together turns "our signup funnel leaks at the pricing page" into "people leave the pricing page for the FAQ — they have unanswered questions." **Endpoints used:** * [`POST /funnels/analyze`](/docs/api/funnels/analyze) — step-by-step conversion and drop-off rates * [`GET /journeys`](/docs/api/insights/journeys) — the most common navigation paths ## The workflow [#the-workflow] <Steps> <Step> **Analyze the funnel** to get a drop-off rate for every step. </Step> <Step> **Find the worst step** — the one with the highest `dropoff_rate`. </Step> <Step> **Pull journeys that start at that step's page** using `stepFilters`, so you can see the most common next page for people who were there. </Step> </Steps> ## Step 1 — Analyze the funnel [#step-1--analyze-the-funnel] Define the path a converting user should take and post it to the analyze endpoint. <Tabs items="['JavaScript', 'Python']"> <Tab value="JavaScript"> ```javascript title="Analyze the funnel" const API = 'https://app.rybbit.io'; const SITE = '123'; const API_KEY = process.env.RYBBIT_API_KEY; const range = { start_date: '2024-01-01', end_date: '2024-01-31', time_zone: 'America/New_York' }; const funnelSteps = [ { type: 'page', value: '/', name: 'Homepage' }, { type: 'page', value: '/pricing', name: 'Pricing' }, { type: 'page', value: '/signup', name: 'Signup' }, { type: 'event', value: 'signup_complete', name: 'Complete' }, ]; async function analyzeFunnel() { const url = new URL(`${API}/api/sites/${SITE}/funnels/analyze`); Object.entries(range).forEach(([k, v]) => url.searchParams.set(k, v)); const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ steps: funnelSteps }), }); const { data } = await res.json(); return data; // array of { step_number, step_name, sessions, conversion_rate, dropoff_rate } } ``` </Tab> <Tab value="Python"> ```python title="Analyze the funnel" import os, requests API = "https://app.rybbit.io" SITE = "123" API_KEY = os.environ["RYBBIT_API_KEY"] RANGE = {"start_date": "2024-01-01", "end_date": "2024-01-31", "time_zone": "America/New_York"} FUNNEL_STEPS = [ {"type": "page", "value": "/", "name": "Homepage"}, {"type": "page", "value": "/pricing", "name": "Pricing"}, {"type": "page", "value": "/signup", "name": "Signup"}, {"type": "event", "value": "signup_complete", "name": "Complete"}, ] def analyze_funnel(): res = requests.post( f"{API}/api/sites/{SITE}/funnels/analyze", params=RANGE, json={"steps": FUNNEL_STEPS}, headers={"Authorization": f"Bearer {API_KEY}"}, ) res.raise_for_status() return res.json()["data"] # list of step results ``` </Tab> </Tabs> A response looks like this — note that `dropoff_rate` is the percentage who left **between the previous step and this one**: ```json { "data": [ { "step_number": 1, "step_name": "Homepage", "sessions": 10000, "conversion_rate": 100, "dropoff_rate": 0 }, { "step_number": 2, "step_name": "Pricing", "sessions": 4500, "conversion_rate": 45, "dropoff_rate": 55 }, { "step_number": 3, "step_name": "Signup", "sessions": 1800, "conversion_rate": 18, "dropoff_rate": 60 }, { "step_number": 4, "step_name": "Complete", "sessions": 1080, "conversion_rate": 10.8, "dropoff_rate": 40 } ] } ``` ## Step 2 — Find the worst step [#step-2--find-the-worst-step] The leak isn't always the step with the fewest sessions — it's the step with the steepest **drop-off**. Pick the maximum `dropoff_rate`. <Tabs items="['JavaScript', 'Python']"> <Tab value="JavaScript"> ```javascript title="Find the biggest leak" function biggestLeak(steps) { // Skip step 1 — it always has 0% drop-off return steps .filter((s) => s.step_number > 1) .reduce((worst, s) => (s.dropoff_rate > worst.dropoff_rate ? s : worst)); } const steps = await analyzeFunnel(); const leak = biggestLeak(steps); const enteredStep = steps[leak.step_number - 2]; // the step they were on before leaving console.log( `Worst leak: ${leak.dropoff_rate}% of users left after "${enteredStep.step_name}" ` + `instead of reaching "${leak.step_name}".` ); ``` </Tab> <Tab value="Python"> ```python title="Find the biggest leak" def biggest_leak(steps): # Skip step 1 — it always has 0% drop-off return max((s for s in steps if s["step_number"] > 1), key=lambda s: s["dropoff_rate"]) steps = analyze_funnel() leak = biggest_leak(steps) entered_step = steps[leak["step_number"] - 2] # the step they were on before leaving print( f'Worst leak: {leak["dropoff_rate"]}% of users left after "{entered_step["step_name"]}" ' f'instead of reaching "{leak["step_name"]}".' ) ``` </Tab> </Tabs> In the example above, the steepest drop-off is **60%, between Pricing and Signup** — of the 4,500 people who reached the pricing page, only 1,800 went on to signup. That makes the **pricing page** the one to investigate: it's where the audience was sitting right before the biggest leak. ## Step 3 — See where the leavers went [#step-3--see-where-the-leavers-went] The [journeys endpoint](/docs/api/insights/journeys) ranks the most common page paths. Use `stepFilters` to anchor step `0` to the page people were dropping from — now every returned journey shows you a common *next* page for that audience. <Tabs items="['JavaScript', 'Python']"> <Tab value="JavaScript"> ```javascript title="What did they do after the leaky page?" async function journeysFrom(page) { const stepFilters = JSON.stringify({ '0': page }); const url = new URL(`${API}/api/sites/${SITE}/journeys`); url.searchParams.set('steps', '2'); url.searchParams.set('limit', '10'); url.searchParams.set('stepFilters', stepFilters); Object.entries(range).forEach(([k, v]) => url.searchParams.set(k, v)); const res = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` } }); const { journeys } = await res.json(); return journeys; } // The leaky step's page — '/pricing' in our example const leakyPage = funnelSteps[leak.step_number - 2].value; const journeys = await journeysFrom(leakyPage); console.log(`Where users go after ${leakyPage}:`); journeys .filter((j) => j.path[1] && j.path[1] !== '/signup') // exclude the intended next step .slice(0, 5) .forEach((j) => console.log(` → ${j.path[1]} (${j.count} sessions, ${j.percentage}%)`)); ``` </Tab> <Tab value="Python"> ```python title="What did they do after the leaky page?" import json def journeys_from(page): res = requests.get( f"{API}/api/sites/{SITE}/journeys", params={"steps": 2, "limit": 10, "stepFilters": json.dumps({"0": page}), **RANGE}, headers={"Authorization": f"Bearer {API_KEY}"}, ) res.raise_for_status() return res.json()["journeys"] # The leaky step's page — '/pricing' in our example leaky_page = FUNNEL_STEPS[leak["step_number"] - 2]["value"] journeys = journeys_from(leaky_page) print(f"Where users go after {leaky_page}:") for j in [j for j in journeys if len(j["path"]) > 1 and j["path"][1] != "/signup"][:5]: print(f' → {j["path"][1]} ({j["count"]} sessions, {j["percentage"]}%)') ``` </Tab> </Tabs> ### The insight [#the-insight] ```text Where users go after /pricing: → /faq (1,240 sessions, 27.6%) → / (820 sessions, 18.2%) → /enterprise (610 sessions, 13.6%) ``` More than a quarter of people who hit Pricing and didn't sign up went to the FAQ — a strong signal that pricing raises questions your FAQ has to answer. That's a far more actionable finding than "the pricing page has a 60% drop-off." <Callout type="info"> **Quantify before you act.** Combine the funnel and journey numbers into a one-line business case: *"2,700 users/month leave Pricing; 28% head to the FAQ. Surfacing the top FAQ answers on the pricing page could recover a meaningful share of them."* </Callout> ## Next steps [#next-steps] * [Compare Marketing Channel Performance](/docs/api/channel-performance) — find out which traffic sources fill the top of this funnel with people who actually convert. * [Funnels API reference](/docs/api/funnels/analyze) — event steps, property matching, and per-step session lookups. # Getting Started (/docs/api/getting-started) <Callout type="warn"> The API is currently in beta. There may be breaking changes as we continue to improve and expand the API. </Callout> ## API Playground [#api-playground] The easiest way to explore and test the API is using the built-in **API Playground** in your Rybbit dashboard. Access it by navigating to any site and clicking "API Playground" in the sidebar. The playground provides: * **Endpoint browser** - Browse all available API endpoints * **Parameter controls** - Configure request parameters with a visual interface * **Exact time ranges** - Test date ranges with optional start and end times * **Live responses** - See real API responses with your actual data * **Code generation** - Copy ready-to-use code snippets This is the recommended way to familiarize yourself with the API before integrating it into your applications. ## Guides [#guides] Looking for what to *build* with the API? These end-to-end recipes chain endpoints together to solve a real problem: * [**Automated Weekly Report**](/docs/api/weekly-report) — a scheduled digest of last week's traffic, week-over-week change, and top pages and sources. * [**Find Where Users Drop Off**](/docs/api/funnel-dropoff) — locate the biggest leak in a funnel, then see where the people who abandon go instead. * [**Compare Channel Performance**](/docs/api/channel-performance) — rank acquisition channels by how well they convert, not just how much traffic they send. * [**Build a Live Visitor Feed**](/docs/api/live-feed) — poll the events endpoint for a real-time activity stream you can render or alert on. * [**Export Events to a Warehouse**](/docs/api/export-events) — backfill your full event history and keep it in sync with an incremental job. ## Authentication [#authentication] All API requests must include authentication using one of the following methods: ### Bearer Token (Recommended) [#bearer-token-recommended] ```bash Authorization: Bearer your_api_key_here ``` ### Query Parameter (Testing Only) [#query-parameter-testing-only] ```bash ?api_key=your_api_key_here ``` <Callout type="warn"> Query parameters expose API keys in server logs and browser history. Use only for quick testing. </Callout> ## Generating API Keys [#generating-api-keys] Rybbit has two kinds of API keys: * **Organization API keys** (recommended for integrations) — the organization's own credential. They can access every site in the organization and keep working when team members leave. Only organization admins and owners can create and manage them. * **Personal API keys** — act as you, with exactly your access, including any site restrictions, across every organization you belong to. Best for personal scripts and for connecting MCP clients. ### Organization keys [#organization-keys] 1. Navigate to **Settings → Organization** in your Rybbit dashboard 2. Scroll to the **Organization API Keys** section (visible to admins and owners) 3. Enter a name for the key and click **Create** 4. Copy the key immediately (it won't be shown again) ### Personal keys [#personal-keys] 1. Navigate to **Settings → Account** in your Rybbit dashboard 2. Scroll to the **Personal API Keys** section 3. Enter a name for the key and click **Create** 4. Copy the key immediately (it won't be shown again) Both kinds support restricting a key to specific permissions when you create it — leave restrictions off for a full-access key. ### Rate Limits (Cloud Only) [#rate-limits-cloud-only] Requests are limited in two ways: a **burst** limit that is the same on every plan, and a **daily quota** that depends on your plan. | Plan | Burst | Daily quota | | ---------------- | -------------------------- | ----------------------- | | **Free / Basic** | API keys not available | — | | **Standard** | 50 requests per 10 seconds | 5,000 requests per day | | **Pro** | 50 requests per 10 seconds | 25,000 requests per day | The burst limit is a bucket of 50 requests that refills continuously at 5 requests per second. You can spend all 50 at once, then keep going at 5 per second — so short spikes are fine and there is no window boundary to line up with. The daily quota resets at **00:00 UTC**. Requests refused by the burst limit don't count against your daily quota. #### Which budget a request is charged to [#which-budget-a-request-is-charged-to] Limits apply per **credential owner**, never per key — creating more keys splits an existing budget rather than adding to it: | Credential | Budget | | -------------------------------- | ------------------------------------------------------------------------------------------- | | **Organization key** (`rb_org_`) | The organization's. All of an organization's keys share it. | | **Personal key** | Yours. All of your personal keys share one budget, across every organization you belong to. | | **OAuth token** (MCP) | Yours, the same budget as your personal keys. | A personal key's quota follows the most generous plan among the organizations you belong to. Self-hosted instances are not rate limited at all — neither tier applies, and no rate limit headers are sent. #### Reading your remaining budget [#reading-your-remaining-budget] Analytics and management endpoints report your current budget on every response, so you can pace a client without waiting to be rejected: | Header | Meaning | | ------------------------------------------- | ---------------------------------------------- | | `X-RateLimit-Burst-Limit` / `-Remaining` | Burst bucket size and tokens left | | `X-RateLimit-Burst-Reset` | Seconds until the bucket is full again | | `X-RateLimit-Daily-Limit` / `-Remaining` | Daily quota and requests left today | | `X-RateLimit-Daily-Reset` | Seconds until 00:00 UTC | | `RateLimit-Limit` / `-Remaining` / `-Reset` | Whichever of the two is closest to running out | Treat these as advisory: a few endpoints resolve credentials outside the standard path and omit them. `Retry-After` on a `429` is always present. #### When you hit a limit [#when-you-hit-a-limit] A `429` names the limit you hit and tells you how long to wait, in seconds: ```json { "error": "Rate limit exceeded", "scope": "daily", "limit": 5000, "retryAfter": 34200 } ``` `scope` is `burst` or `daily`, and the same value is in the `Retry-After` header. The difference matters: a `burst` rejection clears in about a second, while a `daily` one lasts until the quota resets. Back off accordingly rather than retrying straight away. *** ## Common Parameters [#common-parameters] The following parameters are shared across all analytics endpoints. ### Time Parameters [#time-parameters] All endpoints require date-based, exact datetime, or relative time parameters: <TypeTable type="{ start_date: { description: 'Start date for the query period', type: 'string', typeDescription: 'Format: YYYY-MM-DD (e.g., "2024-01-15")', required: false, }, end_date: { description: 'End date for the query period', type: 'string', typeDescription: 'Format: YYYY-MM-DD (e.g., "2024-01-31")', required: false, }, time_zone: { description: 'IANA timezone identifier', type: 'string', typeDescription: 'e.g., "America/New_York", "Europe/London", "Asia/Tokyo". Required with date and exact datetime ranges.', required: false, }, start_datetime: { description: 'Exact UTC start timestamp for the query period', type: 'string', typeDescription: 'Format: YYYY-MM-DD HH:mm:ss (e.g., "2024-01-15 13:00:00")', required: false, }, end_datetime: { description: 'Exact UTC end timestamp for the query period. This boundary is exclusive.', type: 'string', typeDescription: 'Format: YYYY-MM-DD HH:mm:ss (e.g., "2024-01-15 15:00:00")', required: false, }, past_minutes_start: { description: 'Start of relative time range (older boundary)', type: 'number', typeDescription: 'Minutes ago from now. Must be greater than past_minutes_end.', required: false, }, past_minutes_end: { description: 'End of relative time range (newer boundary)', type: 'number', typeDescription: 'Minutes ago from now. Must be less than past_minutes_start.', required: false, }, }" /> **Note:** You must provide either: * All three date parameters (`start_date`, `end_date`, `time_zone`), OR * All three exact datetime parameters (`start_datetime`, `end_datetime`, `time_zone`), OR * Both relative parameters (`past_minutes_start`, `past_minutes_end`) #### Examples [#examples] **Date-based query (last 30 days):** ``` ?start_date=2024-01-01&end_date=2024-01-31&time_zone=America/New_York ``` **Exact datetime query (8 AM to 10 AM in New York on Jan 15, sent as UTC):** ``` ?start_datetime=2024-01-15%2013:00:00&end_datetime=2024-01-15%2015:00:00&time_zone=America/New_York ``` **Relative query (last 60 minutes):** ``` ?past_minutes_start=60&past_minutes_end=0 ``` ### Filter Parameters [#filter-parameters] The `filters` parameter accepts a JSON-encoded array of filter objects to narrow down your data. You can also pass `segment_id`, the ID of a [saved segment](/docs/segments), instead of or alongside `filters`. The segment's filters are expanded on the server and ANDed with any `filters` you pass. Use the [Segments endpoints](/docs/api/segments/list) to find IDs. Public and private-link callers can only use segments marked as shown on the public dashboard. ``` ?start_date=2024-01-01&end_date=2024-01-31&time_zone=UTC&segment_id=12 ``` #### Filter Object Structure [#filter-object-structure] <TypeTable type="{ parameter: { description: 'The dimension to filter on', type: 'FilterParameter', typeDescription: 'See Filter Parameters table below', required: true, }, type: { description: 'The type of comparison to perform', type: 'FilterType', typeDescription: '"equals" | "not_equals" | "contains" | "not_contains" | "regex" | "not_regex" | "greater_than" | "less_than"', required: true, }, value: { description: 'Array of values to match against', type: 'Array<string | number>', typeDescription: 'Multiple values are treated as OR conditions', required: true, }, }" /> #### Filter Types [#filter-types] <TypeTable type="{ equals: { description: 'Exact match', type: 'string', typeDescription: 'Value must exactly match', }, not_equals: { description: 'Exclude exact matches', type: 'string', typeDescription: 'Value must not match', }, contains: { description: 'Substring match', type: 'string', typeDescription: 'Value must contain the substring (uses SQL LIKE %value%)', }, not_contains: { description: 'Exclude substring matches', type: 'string', typeDescription: 'Value must not contain the substring', }, regex: { description: 'Regular expression match', type: 'string', typeDescription: 'Value must match the regex pattern (max 500 characters)', }, not_regex: { description: 'Exclude regex matches', type: 'string', typeDescription: 'Value must not match the regex pattern', }, greater_than: { description: 'Numeric greater than', type: 'number', typeDescription: 'For numeric fields like lat, lon', }, less_than: { description: 'Numeric less than', type: 'number', typeDescription: 'For numeric fields like lat, lon', }, }" /> #### Available Filter Parameters [#available-filter-parameters] **Browser & Device** <TypeTable type="{ browser: { description: 'Browser name', type: 'string', typeDescription: 'e.g., "Chrome", "Firefox", "Safari"', }, browser_version: { description: 'Browser with version', type: 'string', typeDescription: 'e.g., "Chrome 120"', }, operating_system: { description: 'Operating system name', type: 'string', typeDescription: 'e.g., "Windows", "macOS", "Linux"', }, operating_system_version: { description: 'OS with version', type: 'string', typeDescription: 'e.g., "Windows 10/11", "macOS 14"', }, device_type: { description: 'Device category', type: 'string', typeDescription: '"desktop", "mobile", "tablet"', }, dimensions: { description: 'Screen dimensions', type: 'string', typeDescription: 'e.g., "1920x1080", "390x844"', }, language: { description: 'Browser language', type: 'string', typeDescription: 'e.g., "en-US", "es-ES"', }, }" /> **Location** <TypeTable type="{ country: { description: 'Country code', type: 'string', typeDescription: 'ISO 3166-1 alpha-2 code, e.g., "US", "GB"', }, region: { description: 'Geographic region/state', type: 'string', typeDescription: 'e.g., "California", "England"', }, city: { description: 'City name with region prefix', type: 'string', typeDescription: 'Format: "Region-City", e.g., "CA-San Francisco"', }, timezone: { description: 'User timezone', type: 'string', typeDescription: 'IANA timezone, e.g., "America/New_York"', }, lat: { description: 'Latitude', type: 'number', typeDescription: 'Supports greater_than, less_than, equals (with 0.001 tolerance)', }, lon: { description: 'Longitude', type: 'number', typeDescription: 'Supports greater_than, less_than, equals (with 0.001 tolerance)', }, }" /> **Page & Traffic** <TypeTable type="{ pathname: { description: 'URL pathname', type: 'string', typeDescription: 'e.g., "/blog/post-title"', }, page_title: { description: 'HTML page title', type: 'string', }, hostname: { description: 'Website hostname', type: 'string', typeDescription: 'e.g., "example.com"', }, querystring: { description: 'URL query string', type: 'string', typeDescription: 'e.g., "?ref=twitter"', }, referrer: { description: 'Referrer domain (without www)', type: 'string', typeDescription: 'e.g., "google.com", "twitter.com"', }, entry_page: { description: 'First page in session (landing page)', type: 'string', }, exit_page: { description: 'Last page in session', type: 'string', }, channel: { description: 'Marketing channel', type: 'string', typeDescription: 'e.g., "Direct", "Organic Search", "Social"', }, }" /> **UTM Parameters** <TypeTable type="{ utm_source: { description: 'UTM source parameter', type: 'string', typeDescription: 'e.g., "google", "newsletter"', }, utm_medium: { description: 'UTM medium parameter', type: 'string', typeDescription: 'e.g., "cpc", "email"', }, utm_campaign: { description: 'UTM campaign parameter', type: 'string', }, utm_term: { description: 'UTM term parameter', type: 'string', }, utm_content: { description: 'UTM content parameter', type: 'string', }, }" /> **User & Events** <TypeTable type="{ user_id: { description: 'User identifier', type: 'string', typeDescription: 'Matches both device fingerprint and custom identified user ID', }, event_name: { description: 'Custom event name', type: 'string', typeDescription: 'Filters to sessions containing the event', }, }" /> #### Filter Examples [#filter-examples] <Tabs items="['Single Filter', 'Multiple Filters', 'Contains', 'Regex', 'Numeric', 'URL-encoded']"> <Tab value="Single Filter"> ```json [ { "parameter": "country", "type": "equals", "value": ["US"] } ] ``` </Tab> <Tab value="Multiple Filters"> Multiple filters use AND logic: ```json [ { "parameter": "country", "type": "equals", "value": ["US", "CA"] }, { "parameter": "device_type", "type": "equals", "value": ["mobile"] } ] ``` </Tab> <Tab value="Contains"> Substring match: ```json [ { "parameter": "pathname", "type": "contains", "value": ["/blog"] } ] ``` </Tab> <Tab value="Regex"> Pattern match: ```json [ { "parameter": "pathname", "type": "regex", "value": ["^/products/[0-9]+$"] } ] ``` </Tab> <Tab value="Numeric"> Geolocation comparison: ```json [ { "parameter": "lat", "type": "greater_than", "value": ["37.0"] }, { "parameter": "lat", "type": "less_than", "value": ["38.0"] } ] ``` </Tab> <Tab value="URL-encoded"> ``` ?filters=%5B%7B%22parameter%22%3A%22country%22%2C%22type%22%3A%22equals%22%2C%22value%22%3A%5B%22US%22%5D%7D%5D ``` </Tab> </Tabs> *** ## Error Responses [#error-responses] All endpoints return standard HTTP status codes: * **200** - Success * **400** - Bad Request (invalid parameters) * **401** - Unauthorized (missing or invalid API key) * **403** - Forbidden (no access to site) * **404** - Not Found (site doesn't exist) * **429** - Too Many Requests (rate limit exceeded) * **500** - Internal Server Error ### Error Response Format [#error-response-format] ```json { "error": "Error message describing what went wrong" } ``` # Build a Live Visitor Feed (/docs/api/live-feed) Rybbit doesn't push a websocket stream, but the events endpoint is built for polling: pass a `since_timestamp` and you get only the events that happened after it. This guide builds a self-advancing feed loop you can wire into a dashboard, a terminal ticker, or an alerting rule. **Endpoints used:** * [`GET /live-user-count`](/docs/api/overview/live-visitors) — how many visitors are active right now * [`GET /events`](/docs/api/events/list) — the event stream, polled incrementally ## How incremental polling works [#how-incremental-polling-works] The events endpoint returns the newest events first. To stream without duplicates, remember the timestamp of the most recent event you've seen and send it as `since_timestamp` on the next request — the API returns only events newer than that, up to 500 per call. <Steps> <Step> **Fetch the initial batch** to establish a starting watermark (the newest timestamp). </Step> <Step> **On an interval, request events `since_timestamp`** = your watermark. </Step> <Step> **Process the new events** (print, render, alert) and advance the watermark to the newest one returned. </Step> </Steps> <Callout type="info"> `since_timestamp` returns up to **500** events per call. If you expect more than 500 events between polls (a very high-traffic site with a long interval), poll more frequently so each batch stays under the cap. </Callout> ## Full script [#full-script] A Node script that prints a live count plus a rolling feed of activity. The same loop drives a browser dashboard — just replace `console.log` with DOM updates. <Tabs items="['JavaScript', 'Python']"> <Tab value="JavaScript"> ```javascript title="live-feed.js" const API = 'https://app.rybbit.io'; const SITE = '123'; const API_KEY = process.env.RYBBIT_API_KEY; const POLL_MS = 10000; // every 10 seconds async function get(path, params = {}) { const url = new URL(`${API}/api/sites/${SITE}${path}`); Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); const res = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` } }); if (!res.ok) throw new Error(`${path} failed: ${res.status}`); return res.json(); } async function liveCount() { const { count } = await get('/live-user-count', { minutes: 5 }); return count; } function describe(e) { const where = e.city ? `${e.city}, ${e.country}` : e.country || 'Unknown'; if (e.type === 'pageview') return `👀 viewed ${e.pathname} — ${where}`; if (e.type === 'custom_event') return `⚡ ${e.event_name} (${e.pathname}) — ${where}`; if (e.type === 'outbound') return `↗️ left via ${e.pathname} — ${where}`; return `• ${e.type} — ${where}`; } let watermark = null; // ISO timestamp of the newest event we've shown async function poll() { try { const params = watermark ? { since_timestamp: watermark } : { page_size: 20 }; const [{ data: events }, count] = await Promise.all([get('/events', params), liveCount()]); // Events come back newest-first; show oldest-first so the feed reads top-to-bottom for (const e of [...events].reverse()) { console.log(`[${new Date(e.timestamp).toLocaleTimeString()}] ${describe(e)}`); } if (events.length) watermark = events[0].timestamp; // newest event becomes the new watermark process.stdout.write(`\n🟢 ${count} visitors online\n`); } catch (err) { console.error('poll error:', err.message); } } console.log('Starting live feed… (Ctrl+C to stop)'); poll(); setInterval(poll, POLL_MS); ``` </Tab> <Tab value="Python"> ```python title="live_feed.py" import os, time from datetime import datetime import requests API = "https://app.rybbit.io" SITE = "123" API_KEY = os.environ["RYBBIT_API_KEY"] POLL_SECONDS = 10 session = requests.Session() session.headers["Authorization"] = f"Bearer {API_KEY}" def get(path, **params): res = session.get(f"{API}/api/sites/{SITE}{path}", params=params) res.raise_for_status() return res.json() def live_count(): return get("/live-user-count", minutes=5)["count"] def describe(e): where = f'{e["city"]}, {e["country"]}' if e.get("city") else e.get("country") or "Unknown" if e["type"] == "pageview": return f'👀 viewed {e["pathname"]} — {where}' if e["type"] == "custom_event": return f'⚡ {e["event_name"]} ({e["pathname"]}) — {where}' if e["type"] == "outbound": return f'↗️ left via {e["pathname"]} — {where}' return f'• {e["type"]} — {where}' watermark = None # ISO timestamp of the newest event we've shown print("Starting live feed… (Ctrl+C to stop)") while True: try: params = {"since_timestamp": watermark} if watermark else {"page_size": 20} events = get("/events", **params)["data"] # Events come back newest-first; show oldest-first so the feed reads top-to-bottom for e in reversed(events): ts = datetime.fromisoformat(e["timestamp"].replace("Z", "+00:00")).strftime("%H:%M:%S") print(f"[{ts}] {describe(e)}") if events: watermark = events[0]["timestamp"] # newest event becomes the new watermark print(f"\n🟢 {live_count()} visitors online\n") except Exception as err: print(f"poll error: {err}") time.sleep(POLL_SECONDS) ``` </Tab> </Tabs> ## Example output [#example-output] ```text Starting live feed… (Ctrl+C to stop) [14:22:01] 👀 viewed /pricing — San Francisco, US [14:22:03] ⚡ signup (/signup) — London, GB [14:22:05] ↗️ left via /docs — Berlin, DE 🟢 42 visitors online ``` ## Turn it into an alert [#turn-it-into-an-alert] Because each poll hands you the raw events, you can act on them instead of just printing. For example, ping a channel whenever someone hits your enterprise pricing page: <Tabs items="['JavaScript']"> <Tab value="JavaScript"> ```javascript title="React to specific events" for (const e of events) { if (e.type === 'pageview' && e.pathname === '/enterprise') { await fetch(process.env.SLACK_WEBHOOK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: `🏢 Enterprise page visit from ${e.city || e.country} (${e.referrer || 'direct'})`, }), }); } } ``` </Tab> </Tabs> <Callout type="warn"> **Mind the daily quota.** On Rybbit Cloud, a continuous poll is measured against your plan's daily quota (see [Rate Limits](/docs/api/getting-started#rate-limits-cloud-only)), not just its per-second rate. A 10-second poll running around the clock is 8,640 requests/day — over the Standard quota of 5,000, and 17,280 with the live-count call added. Poll every 20-30 seconds, or only while someone is actually watching the page, and check `X-RateLimit-Daily-Remaining` to see where you stand. Self-hosted instances are not rate limited. </Callout> ## Next steps [#next-steps] * [Events API reference](/docs/api/events/list) — full event object fields, plus `before_timestamp` for scrolling backwards through history. * [Export Raw Events to Your Warehouse](/docs/api/export-events) — the same `since_timestamp` mechanism, used for durable incremental syncs instead of a live view. # Sending Events (/docs/api/sending-events) <div className="grid grid-cols-1 xl:grid-cols-2 gap-8"> <div> ``` POST /api/track ``` Track pageviews, custom events, performance, and errors from server-side applications, mobile apps, or any platform. An API key is optional but recommended for server-side tracking — it bypasses bot detection and domain validation. IP addresses are resolved to geolocation and user agents are parsed for browser/OS/device info. ### Request Body [#request-body] All parameters are sent in the request body as JSON. <TypeTable type="{ site_id: { description: 'Your site ID', type: 'string', required: true, }, type: { description: 'Event type', type: 'string', typeDescription: '"pageview" | "custom_event" | "performance" | "outbound" | "error"', default: 'pageview', }, pathname: { description: 'Page path', type: 'string', typeDescription: 'Max 2048 chars', }, hostname: { description: 'Domain name', type: 'string', typeDescription: 'Max 253 chars', }, page_title: { description: 'Page title', type: 'string', typeDescription: 'Max 512 chars', }, referrer: { description: 'Referrer URL', type: 'string', typeDescription: 'Max 2048 chars', }, user_id: { description: 'Custom user identifier', type: 'string', typeDescription: 'Max 255 chars', }, user_agent: { description: 'Custom user agent string - will be parsed for browser/OS/device info', type: 'string', typeDescription: 'Max 512 chars', }, ip_address: { description: 'Custom IP for geolocation - will be resolved to location data', type: 'string', typeDescription: 'Valid IPv4 or IPv6 address', }, querystring: { description: 'Query parameters', type: 'string', typeDescription: 'Max 2048 chars', }, language: { description: 'Language code', type: 'string', typeDescription: 'e.g., "en", "es", "fr"', }, screenWidth: { description: 'Screen width in logical pixels (density-independent pixels)', type: 'number', }, screenHeight: { description: 'Screen height in logical pixels (density-independent pixels)', type: 'number', }, event_name: { description: 'Event name (required for custom events)', type: 'string', typeDescription: 'Max 256 chars', }, properties: { description: 'JSON string with event data (for custom events, outbound, error)', type: 'string', typeDescription: 'Max 2048 chars (4096 for error events)', }, feature_flags: { description: 'Optional map of feature flag keys to assigned values. Useful for server-side flag evaluation and filtering analytics by flag value.', type: 'Record<string, string>', typeDescription: 'Keys max 100 chars, values max 2048 chars', }, }" /> #### Pageview [#pageview] Standard page view tracking. Set `type: "pageview"` (default). #### Custom Event [#custom-event] Track custom events with optional properties. Set `type: "custom_event"`. <TypeTable type="{ event_name: { description: 'Name of the custom event', type: 'string', typeDescription: 'Max 256 chars', required: true, }, properties: { description: 'Event data as JSON string', type: 'string', typeDescription: 'Valid JSON, max 2048 chars', }, }" /> #### Performance [#performance] Track Core Web Vitals. Set `type: "performance"`. <TypeTable type="{ lcp: { description: 'Largest Contentful Paint in milliseconds', type: 'number', typeDescription: 'nullable', }, cls: { description: 'Cumulative Layout Shift score', type: 'number', typeDescription: 'nullable, typically 0-1', }, inp: { description: 'Interaction to Next Paint in milliseconds', type: 'number', typeDescription: 'nullable', }, fcp: { description: 'First Contentful Paint in milliseconds', type: 'number', typeDescription: 'nullable', }, ttfb: { description: 'Time to First Byte in milliseconds', type: 'number', typeDescription: 'nullable', }, }" /> #### Outbound [#outbound] Track outbound link clicks. Set `type: "outbound"`. Pass details in the `properties` JSON string. <TypeTable type="{ url: { description: 'Destination URL', type: 'string', required: true, }, text: { description: 'Link text content', type: 'string', }, target: { description: 'Link target attribute', type: 'string', typeDescription: 'e.g., "_blank", "_self"', }, }" /> #### Error [#error] Track JavaScript errors. Set `type: "error"`. `event_name` is the error type and `properties` carries the error details as a JSON string. <TypeTable type="{ message: { description: 'Error message', type: 'string', typeDescription: 'Max 500 chars', required: true, }, stack: { description: 'Stack trace', type: 'string', typeDescription: 'Max 2000 chars', }, fileName: { description: 'File where error occurred', type: 'string', }, lineNumber: { description: 'Line number', type: 'number', }, columnNumber: { description: 'Column number', type: 'number', }, }" /> ### Response [#response] <TypeTable type="{ success: { description: 'Whether the event was accepted', type: 'boolean', }, }" /> ### Rate Limiting [#rate-limiting] API keys are limited to **500 requests per 10 minutes**. Exceeding the limit returns a `429` status code. </div> <div className="flex flex-col gap-4"> <Tabs items="['cURL', 'JavaScript', 'Python', 'PHP', 'Ruby', 'Go', 'Rust', 'Java', '.NET']"> <Tab value="cURL"> ```bash title="Request" curl -X POST "https://app.rybbit.io/api/track" \ -H "Authorization: Bearer your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "site_id": "1", "type": "custom_event", "pathname": "/checkout", "event_name": "purchase", "properties": "{\"amount\": 99.99, \"currency\": \"USD\"}" }' ``` </Tab> <Tab value="JavaScript"> ```javascript title="Request" const response = await fetch('https://app.rybbit.io/api/track', { method: 'POST', headers: { 'Authorization': 'Bearer your_api_key_here', 'Content-Type': 'application/json' }, body: JSON.stringify({ site_id: '1', type: 'custom_event', pathname: '/checkout', event_name: 'purchase', properties: JSON.stringify({ amount: 99.99, currency: 'USD' }) }) }); const data = await response.json(); ``` </Tab> <Tab value="Python"> ```python title="Request" import requests import json response = requests.post( 'https://app.rybbit.io/api/track', json={ 'site_id': '1', 'type': 'custom_event', 'pathname': '/checkout', 'event_name': 'purchase', 'properties': json.dumps({'amount': 99.99, 'currency': 'USD'}) }, headers={ 'Authorization': 'Bearer your_api_key_here' } ) data = response.json() ``` </Tab> <Tab value="PHP"> ```php title="Request" $body = [ 'site_id' => '1', 'type' => 'custom_event', 'pathname' => '/checkout', 'event_name' => 'purchase', 'properties' => json_encode(['amount' => 99.99, 'currency' => 'USD']) ]; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, 'https://app.rybbit.io/api/track'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer your_api_key_here', 'Content-Type: application/json' ]); $response = curl_exec($ch); curl_close($ch); $data = json_decode($response, true); ``` </Tab> <Tab value="Ruby"> ```ruby title="Request" require 'net/http' require 'json' uri = URI('https://app.rybbit.io/api/track') request = Net::HTTP::Post.new(uri) request['Authorization'] = 'Bearer your_api_key_here' request['Content-Type'] = 'application/json' request.body = { site_id: '1', type: 'custom_event', pathname: '/checkout', event_name: 'purchase', properties: { amount: 99.99, currency: 'USD' }.to_json }.to_json response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| http.request(request) end data = JSON.parse(response.body) ``` </Tab> <Tab value="Go"> ```go title="Request" body := map[string]interface{}{ "site_id": "1", "type": "custom_event", "pathname": "/checkout", "event_name": "purchase", "properties": `{"amount": 99.99, "currency": "USD"}`, } jsonBody, _ := json.Marshal(body) req, _ := http.NewRequest("POST", "https://app.rybbit.io/api/track", bytes.NewBuffer(jsonBody)) req.Header.Set("Authorization", "Bearer your_api_key_here") req.Header.Set("Content-Type", "application/json") client := &http.Client{} resp, _ := client.Do(req) defer resp.Body.Close() var data map[string]interface{} json.NewDecoder(resp.Body).Decode(&data) ``` </Tab> <Tab value="Rust"> ```rust title="Request" let body = serde_json::json!({ "site_id": "1", "type": "custom_event", "pathname": "/checkout", "event_name": "purchase", "properties": r#"{"amount": 99.99, "currency": "USD"}"# }); let client = reqwest::Client::new(); let res = client .post("https://app.rybbit.io/api/track") .header("Authorization", "Bearer your_api_key_here") .json(&body) .send() .await?; let data: serde_json::Value = res.json().await?; ``` </Tab> <Tab value="Java"> ```java title="Request" String json = """ { "site_id": "1", "type": "custom_event", "pathname": "/checkout", "event_name": "purchase", "properties": "{\"amount\": 99.99, \"currency\": \"USD\"}" } """; HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://app.rybbit.io/api/track")) .header("Authorization", "Bearer your_api_key_here") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); ``` </Tab> <Tab value=".NET"> ```csharp title="Request" var body = new { site_id = "1", type = "custom_event", pathname = "/checkout", event_name = "purchase", properties = JsonSerializer.Serialize(new { amount = 99.99, currency = "USD" }) }; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("Authorization", "Bearer your_api_key_here"); var content = new StringContent( JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); var response = await client.PostAsync("https://app.rybbit.io/api/track", content); var data = await response.Content.ReadAsStringAsync(); ``` </Tab> </Tabs> ```json title="Success" { "success": true } ``` ```json title="Error" { "success": false, "error": "Invalid payload", "details": { "fieldErrors": {}, "formErrors": ["Custom events require event_name"] } } ``` </div> </div> # Automated Weekly Report (/docs/api/weekly-report) This guide builds a script you can run on a schedule (a cron job, GitHub Action, or serverless function) that pulls last week's numbers, compares them to the week before, and posts a digest to Slack, email, or anywhere else. **Endpoints used:** * [`GET /overview`](/docs/api/overview/overview) — headline metrics for a date range * [`GET /metric`](/docs/api/overview/metric) — top pages and traffic sources <Callout type="info"> Every analytics endpoint takes the same [time parameters](/docs/api/getting-started#time-parameters). The trick behind any comparison report is simply calling the same endpoint twice with two adjacent date ranges. </Callout> ## The workflow [#the-workflow] <Steps> <Step> **Fetch this week's overview** for the last 7 days. </Step> <Step> **Fetch last week's overview** for the 7 days before that, so you can compute the change. </Step> <Step> **Fetch top pages and top sources** from the `metric` endpoint to add context. </Step> <Step> **Format and deliver** the digest. </Step> </Steps> ## Full script [#full-script] This example posts a formatted message to a Slack incoming webhook, but the `buildReport` function returns plain data you can route anywhere. <Tabs items="['JavaScript', 'Python']"> <Tab value="JavaScript"> ```javascript title="weekly-report.js" const API = 'https://app.rybbit.io'; const SITE = '123'; const API_KEY = process.env.RYBBIT_API_KEY; const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK; const TZ = 'America/New_York'; // Helper: format a Date as YYYY-MM-DD const ymd = (d) => d.toISOString().slice(0, 10); async function get(path, params) { const url = new URL(`${API}/api/sites/${SITE}${path}`); Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); const res = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` }, }); if (!res.ok) throw new Error(`${path} failed: ${res.status}`); return res.json(); } async function overview(start, end) { const { data } = await get('/overview', { start_date: ymd(start), end_date: ymd(end), time_zone: TZ, }); return data; } async function topMetric(parameter, start, end, limit = 5) { const { data } = await get('/metric', { parameter, start_date: ymd(start), end_date: ymd(end), time_zone: TZ, limit, }); return data.data; // metric nests results under data.data } function pctChange(current, previous) { if (!previous) return null; return ((current - previous) / previous) * 100; } async function buildReport() { const now = new Date(); const thisWeekStart = new Date(now); thisWeekStart.setDate(now.getDate() - 7); const lastWeekEnd = new Date(now); lastWeekEnd.setDate(now.getDate() - 8); const lastWeekStart = new Date(now); lastWeekStart.setDate(now.getDate() - 14); const [thisWeek, lastWeek, topPages, topSources] = await Promise.all([ overview(thisWeekStart, now), overview(lastWeekStart, lastWeekEnd), topMetric('pathname', thisWeekStart, now), topMetric('referrer', thisWeekStart, now), ]); return { sessions: { value: thisWeek.sessions, change: pctChange(thisWeek.sessions, lastWeek.sessions) }, users: { value: thisWeek.users, change: pctChange(thisWeek.users, lastWeek.users) }, pageviews:{ value: thisWeek.pageviews, change: pctChange(thisWeek.pageviews, lastWeek.pageviews) }, bounceRate: thisWeek.bounce_rate, topPages, topSources, }; } function arrow(change) { if (change === null) return ''; const sign = change >= 0 ? '▲' : '▼'; return ` ${sign} ${Math.abs(change).toFixed(1)}%`; } async function postToSlack(r) { const lines = [ `*📊 Weekly Analytics Report*`, ``, `• Sessions: *${r.sessions.value.toLocaleString()}*${arrow(r.sessions.change)}`, `• Unique users: *${r.users.value.toLocaleString()}*${arrow(r.users.change)}`, `• Pageviews: *${r.pageviews.value.toLocaleString()}*${arrow(r.pageviews.change)}`, `• Bounce rate: *${r.bounceRate.toFixed(1)}%*`, ``, `*Top pages*`, ...r.topPages.map((p) => ` ${p.value} — ${p.count.toLocaleString()}`), ``, `*Top sources*`, ...r.topSources.map((s) => ` ${s.value || 'Direct'} — ${s.count.toLocaleString()}`), ]; await fetch(SLACK_WEBHOOK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: lines.join('\n') }), }); } buildReport().then(postToSlack).catch(console.error); ``` </Tab> <Tab value="Python"> ```python title="weekly_report.py" import os from datetime import datetime, timedelta import requests API = "https://app.rybbit.io" SITE = "123" API_KEY = os.environ["RYBBIT_API_KEY"] SLACK_WEBHOOK = os.environ["SLACK_WEBHOOK"] TZ = "America/New_York" session = requests.Session() session.headers["Authorization"] = f"Bearer {API_KEY}" def ymd(d: datetime) -> str: return d.strftime("%Y-%m-%d") def get(path: str, **params): res = session.get(f"{API}/api/sites/{SITE}{path}", params=params) res.raise_for_status() return res.json() def overview(start, end): return get("/overview", start_date=ymd(start), end_date=ymd(end), time_zone=TZ)["data"] def top_metric(parameter, start, end, limit=5): # metric nests results under data.data return get("/metric", parameter=parameter, start_date=ymd(start), end_date=ymd(end), time_zone=TZ, limit=limit)["data"]["data"] def pct_change(current, previous): return None if not previous else (current - previous) / previous * 100 def build_report(): now = datetime.utcnow() this_start = now - timedelta(days=7) last_end = now - timedelta(days=8) last_start = now - timedelta(days=14) this_week = overview(this_start, now) last_week = overview(last_start, last_end) return { "sessions": (this_week["sessions"], pct_change(this_week["sessions"], last_week["sessions"])), "users": (this_week["users"], pct_change(this_week["users"], last_week["users"])), "pageviews": (this_week["pageviews"], pct_change(this_week["pageviews"], last_week["pageviews"])), "bounce_rate": this_week["bounce_rate"], "top_pages": top_metric("pathname", this_start, now), "top_sources": top_metric("referrer", this_start, now), } def arrow(change): if change is None: return "" return f" {'▲' if change >= 0 else '▼'} {abs(change):.1f}%" def post_to_slack(r): lines = [ "*📊 Weekly Analytics Report*", "", f"• Sessions: *{r['sessions'][0]:,}*{arrow(r['sessions'][1])}", f"• Unique users: *{r['users'][0]:,}*{arrow(r['users'][1])}", f"• Pageviews: *{r['pageviews'][0]:,}*{arrow(r['pageviews'][1])}", f"• Bounce rate: *{r['bounce_rate']:.1f}%*", "", "*Top pages*", *[f" {p['value']} — {p['count']:,}" for p in r["top_pages"]], "", "*Top sources*", *[f" {s['value'] or 'Direct'} — {s['count']:,}" for s in r["top_sources"]], ] requests.post(SLACK_WEBHOOK, json={"text": "\n".join(lines)}) post_to_slack(build_report()) ``` </Tab> </Tabs> ## Example output [#example-output] ```text 📊 Weekly Analytics Report • Sessions: 15,420 ▲ 12.3% • Unique users: 12,847 ▲ 9.8% • Pageviews: 48,933 ▼ 2.1% • Bounce rate: 42.8% Top pages / — 8,230 /pricing — 3,120 /blog/getting-started — 1,940 Top sources google.com — 6,410 Direct — 4,220 twitter.com — 1,180 ``` ## Schedule it [#schedule-it] Run the script every Monday morning with cron: ```bash title="crontab -e" # Every Monday at 9:00 AM 0 9 * * 1 RYBBIT_API_KEY=xxx SLACK_WEBHOOK=https://hooks.slack.com/... node /path/to/weekly-report.js ``` <Callout type="info"> **Want a richer report?** The same pattern extends naturally. Add `topMetric('channel', ...)` for marketing channels, `topMetric('country', ...)` for geography, or call the [`time-series` endpoint](/docs/api/overview/time-series) with `bucket=day` to attach a 7-point trend line. </Callout> ## Next steps [#next-steps] * [Compare Marketing Channel Performance](/docs/api/channel-performance) — go beyond traffic counts and rank sources by how well they convert. * [Export Raw Events to Your Warehouse](/docs/api/export-events) — if you'd rather build reports in your own BI tool. # Dashboard Embed (/docs/embeds/dashboard) The dashboard embed lets you place the main Rybbit analytics dashboard on another site with a self-contained iframe. Dashboard embeds use private-link routes with `embed=true`. ## Try It Out [#try-it-out] Configure the private-link dashboard embed URL below. The generated snippet uses the same private-link route created in the Rybbit dashboard. <DashboardEmbedDemo /> ## Requirements [#requirements] Dashboard embeds require a generated private link. The private link key is included in the route, for example `/{siteId}/{privateLinkKey}/main`. Unlike [Widget Embeds](/docs/embeds/widget), dashboard embeds expose the full read-only analytics dashboard, not just the compact live visitor widget. <Callout type="warn"> Private links are not listed publicly, but anyone with the private link can view the dashboard. Treat the iframe URL like a shareable read-only secret. </Callout> ## Setup [#setup] 1. Open your site in the Rybbit dashboard and click the settings icon. 2. Go to the **Dashboard Embed** tab. 3. Generate a private link if one does not already exist. 4. Choose the embed theme: light, dark, or system. 5. Choose whether to hide the dashboard sidebar. 6. Copy the generated `<iframe>` snippet and paste it anywhere on your site. ## Appearance [#appearance] * **Theme**: `light`, `dark`, or `system`. `system` follows the viewer's operating system color-scheme preference. * **Hide sidebar**: removes the dashboard sidebar and keeps viewers on the main dashboard page. * **Footer**: the Rybbit footer is hidden automatically in embed mode. ## Example [#example] ```html <iframe src="https://app.rybbit.io/YOUR_SITE_ID/YOUR_PRIVATE_LINK_KEY/main?embed=true&theme=system&hideSidebar=true" style="border: 0; width: 100%; height: 720px;" loading="lazy" title="Analytics dashboard" ></iframe> ``` If you self-host Rybbit, replace `app.rybbit.io` with your own domain. ## URL Parameters [#url-parameters] | Parameter | Values | Description | | ------------- | ------------------------- | ------------------------------------------------------------------------- | | `embed` | `true` | Enables embed mode | | `theme` | `light`, `dark`, `system` | Sets the embedded dashboard color scheme. Defaults to `system` | | `hideSidebar` | `true`, `false` | Hides the dashboard sidebar and keeps visitors on the main dashboard page | When `hideSidebar=true`, only the main dashboard page is shown in the iframe. Leave it off if you want viewers to use the dashboard sidebar to navigate other analytics pages allowed by the private link. The private key is part of the path, not a query parameter: `/{siteId}/{privateLinkKey}/main?embed=true`. # Widget Embeds (/docs/embeds/widget) Widget Embeds let you place a compact live visitor widget on another site with a self-contained iframe. There is no script tag, no JavaScript on your site, and no dependency on the Rybbit tracking script. ## Try It [#try-it] Configure the widget below to see how it'll look. The preview shows live data from [demo.rybbit.com](https://demo.rybbit.com). <EmbedWidgetDemo /> ## Requirements [#requirements] The widget needs a public read-only data endpoint, so you must enable **Widget Embeds** on the site. This is separate from **Public Analytics**. Turning it on only exposes the live count, time series, and top countries to the widget endpoint, not your full dashboard. <Callout type="info"> Enabling Widget Embeds does **not** make your dashboard public. Only the data shown in the widget itself (visitor count, per-minute / per-hour / per-day series, top 5 countries) is accessible; no individual sessions, events, pages, or referrers are exposed. </Callout> ## Setup [#setup] 1. Open your site in the Rybbit dashboard and click the settings icon. 2. Go to the **Widget Embeds** tab. 3. Toggle **Widget Enabled** on. 4. Pick your options: variant, time window, theme, accent color, and display settings. 5. Copy the generated `<iframe>` snippet and paste it anywhere on your site. ## Variants [#variants] ### Card [#card] A self-contained dashboard card showing the live count, an optional bar chart of users over time, and an optional list of top countries. | Option | Values | Default | Description | | ------------- | ------------------------------ | ------- | -------------------------------------------------------- | | Time window | 30 minutes / 24 hours / 7 days | 30 min | Window the count and chart cover | | Bar chart | on / off | on | Show a bar chart bucketed per minute / hour / day | | Top countries | on / off | on | List the top 5 countries in the window | | Width | 240-800 px | 360 px | Iframe width; use `max-width: 100%` for responsive sites | ### Inline Pill [#inline-pill] A compact pill that fits inline with your site's chrome, such as in a header or footer. It shows the pulsing dot, the live count over the last 30 minutes, and the Rybbit logo. ## Appearance [#appearance] * **Theme**: `dark` or `light`. The widget background is transparent, so rounded-corner edges blend into whatever sits behind the iframe. * **Accent color**: any hex color. Used for the pulsing live-indicator dot and the bar-chart bars. Defaults to Rybbit emerald. ## Example [#example] ```html <iframe src="https://app.rybbit.io/widget/YOUR_SITE_ID?variant=card&theme=dark&minutes=30&chart=true&countries=true" style="border: 0; width: 360px; height: 390px; max-width: 100%;" loading="lazy" title="Live visitors" ></iframe> ``` If you self-host Rybbit, replace `app.rybbit.io` with your own domain. ## URL Parameters [#url-parameters] You don't usually need to build the URL by hand because the dashboard generates the snippet for you, but the widget URL accepts these parameters: | Parameter | Values | Description | | ----------- | -------------------------------------- | ----------------------------------------- | | `variant` | `card`, `inline` | Layout variant | | `theme` | `dark`, `light` | Color scheme | | `accent` | 6-character hex, no `#`, e.g. `10b981` | Pulse / bar color | | `minutes` | `30`, `1440`, `10080` | Time window in minutes, card variant only | | `chart` | `true`, `false` | Show the bar chart, card variant only | | `countries` | `true`, `false` | Show top countries, card variant only | ## Caching [#caching] The widget HTML and the underlying stats endpoint are both cached for 60 seconds on the server, so embedding the widget on a high-traffic page won't put pressure on your Rybbit instance. The visible count refreshes once per minute. # Errors (/docs/feature-guides/errors) The Errors tab shows JavaScript errors occurring on your website, helping you identify and fix bugs that affect user experience. ## Enabling Error Tracking [#enabling-error-tracking] Enable error tracking in your [site settings](/docs/site-settings). ## Error Information [#error-information] For each error, you'll see: * **Error Type** - ReferenceError, TypeError, SyntaxError, NetworkError, etc. * **Error Message** - The specific error message * **Stack Trace** - The chain of function calls that led to the error * **Affected Sessions** - Number of unique sessions in which this error occurred * **Occurrence Count** - How many times the error was triggered Expanding an error shows its individual occurrences, including the timestamp and page for each event. * **Browser & Device** - Which environments experience this error ## Using Error Data [#using-error-data] * **Prioritize by impact** - Fix errors affecting the most users first * **Monitor after deployments** - Watch for new errors after releasing code * **Debug user reports** - Find the exact technical issue when users report problems * **Track third-party issues** - Identify when external integrations are malfunctioning # Events (/docs/feature-guides/events) The Events tab shows custom events tracked on your website. ## Setting Up Events [#setting-up-events] See the [Track Events guide](/docs/track-events) for implementation details. ## Events Page [#events-page] The events page includes: * **Events Chart** - Visualize event trends over time * **Event Log** - Recent individual event occurrences (click any row to open a details sheet) The **Custom Events List** — all tracked events with counts — lives in the Events section of the main dashboard. ## Event Metrics [#event-metrics] For each event in the Custom Events List: * **Event Name** - The name you assigned to the event * **Count** - Total times triggered in the selected period * **Percentage** - Share of total events ## Event Properties [#event-properties] In the Custom Events List, click the expand arrow on any event to view its properties. Properties show the key-value pairs attached to events, helping you understand event context (e.g., which product was purchased, what button was clicked). ## Filtering [#filtering] * Click any event name to filter the dashboard to sessions containing that event * Use the filter panel to combine event filters with other dimensions * Filter by time range to analyze event trends # Globe (/docs/feature-guides/globe) The Globe tab shows where your visitors connect from on an interactive map. ## View Modes [#view-modes] ### 2D Map [#2d-map] * **Timeline** - View sessions by timeframe, click individual sessions for details * **Coordinates** - Sessions as dots with color intensity showing traffic volume * **Countries** - Countries highlighted by traffic volume * **Subdivisions** - Granular view by national subdivisions ### 3D Globe [#3d-globe] Same four views as 2D, plus: * Rotate by dragging * Zoom in and out * Traffic hotspots glow The 3D view requires a Mapbox API token. Self-hosted installations need `MAPBOX_TOKEN` in environment variables. Cloud includes this automatically. ## Time Controls [#time-controls] Pick date ranges to see how geographic distribution changes over time. ## Filtering [#filtering] Apply the same filters as the main dashboard to see geographic distribution for specific segments. # User Journeys (/docs/feature-guides/journeys) <img alt="User Journeys Dashboard" src="__img0" /> User journeys show the actual paths people take as they move through your website, revealing how they flow from page to page. ## Reading the Visualization [#reading-the-visualization] ### Nodes [#nodes] Each bar represents a page or event. Its height indicates how many users reached that point. ### Connections [#connections] Lines between nodes show user flow. Thicker connections mean more users took that path. ### Flow Direction [#flow-direction] The visualization flows left to right, showing progression through time. ## Key Patterns to Look For [#key-patterns-to-look-for] * **Happy Path** - The most common route to conversion * **Drop-off Points** - Where many users enter but few continue * **Backtracking** - Users moving backward, often indicating confusion * **Alternative Routes** - Unexpected paths to important pages ## Using Journey Insights [#using-journey-insights] * **Simplify complex paths** - Create shortcuts to important pages * **Fix drop-off points** - Investigate why users leave at certain pages * **Amplify successful paths** - Make high-converting routes easier to follow * **Reduce backtracking** - Improve navigation where users get lost ## Analysis Types [#analysis-types] * **Entry point analysis** - Where do users go from a specific page? * **Goal-based analysis** - How did converting users get there? * **Full journey mapping** - Overall flow from entry to exit # Main Dashboard (/docs/feature-guides/main-tab) <img alt="Main Dashboard" src="__img0" /> The main dashboard is your home base in Rybbit Analytics, showing all key metrics at a glance. ## Top Metrics [#top-metrics] * **Unique Users** - Different people who visited your site * **Total Sessions** - Number of visits (30-minute inactivity starts a new session) * **Total Pageviews** - Every page load from every visitor * **Bounce Rate** - Percentage of single-page sessions * **Pages Per Session** - Average pages viewed per visit * **Session Duration** - Average time spent per visit ## Time Controls [#time-controls] Select date ranges: Today, Yesterday, Last 7/30/60 days, This Week, This Month, All Time, or a custom range. ## Filters [#filters] Combine filters for granular analysis: * Device type, browser, OS * Country, city * Referrer, UTM parameters * Custom event properties A set of filters you use often can be saved as a [segment](/docs/segments) from the **Segments** tab of the Filter button, then applied to any report with one click. ## Main Chart [#main-chart] Shows your selected metric over time. Click the metric selector to switch between Users, Sessions, Pageviews, Bounce Rate, Pages Per Session, or Session Duration. ## Traffic Sources [#traffic-sources] See where your traffic comes from: * **Direct** - Typed URL or bookmarks * **Search** - Google, Bing, DuckDuckGo, etc. * **Social** - Twitter, Facebook, LinkedIn, Reddit * **Referral** - Other websites Click any source to filter the entire dashboard to that traffic. ## UTM Parameters [#utm-parameters] View campaign data: utm\_campaign, utm\_source, utm\_medium, utm\_term, utm\_content. ## Additional Breakdowns [#additional-breakdowns] * **Top Pages** - Most visited URLs with pageviews, visitors, time on page, and bounce rate * **Entry/Exit Pages** - Where visits begin and end * **Devices & Browsers** - Desktop/mobile/tablet split, browser and OS breakdown * **Screen Resolutions** - What sizes your visitors use * **Geographic Data** - Countries and regions * **Custom Events** - Click any event to filter to sessions containing it # Pages (/docs/feature-guides/pages-tab) <img alt="Pages Dashboard" src="__img0" /> The Pages tab shows detailed analytics for every URL on your site. ## Page Metrics [#page-metrics] Each page displays: * **Page URL** - The full path * **Sessions** - Number of sessions that viewed this page * **Average Time on Page** - How long visitors spend there ## Filtering [#filtering] Use the same filters as the main dashboard to analyze specific segments: * Mobile users from social media * Desktop users who converted * Users who triggered specific events ## Identifying Issues [#identifying-issues] * Scan for pages with low time on page to find content that isn't engaging * Look for pages with few sessions to find underperforming pages * Compare entry pages to see which content attracts traffic # Performance (/docs/feature-guides/performance) <img alt="Performance Dashboard" src="__img0" /> The Performance tab tracks Core Web Vitals from real user data. ## Enabling Performance Tracking [#enabling-performance-tracking] Enable Web Vitals in your [site settings](/docs/site-settings). ## Web Vitals [#web-vitals] ### Largest Contentful Paint (LCP) [#largest-contentful-paint-lcp] How long the main content takes to load. * **Good**: Under 2.5 seconds * **Needs Improvement**: 2.5-4 seconds * **Poor**: Over 4 seconds ### Cumulative Layout Shift (CLS) [#cumulative-layout-shift-cls] How much content jumps around while loading. * **Good**: Under 0.1 * **Needs Improvement**: 0.1-0.25 * **Poor**: Over 0.25 ### Interaction to Next Paint (INP) [#interaction-to-next-paint-inp] How quickly your site responds to user interactions. * **Good**: Under 200ms * **Needs Improvement**: 200-500ms * **Poor**: Over 500ms ### First Contentful Paint (FCP) [#first-contentful-paint-fcp] How long until the first content appears. * **Good**: Under 1.8 seconds * **Needs Improvement**: 1.8-3 seconds * **Poor**: Over 3 seconds ### Time to First Byte (TTFB) [#time-to-first-byte-ttfb] How long until the server responds. * **Good**: Under 800ms * **Needs Improvement**: 800-1800ms * **Poor**: Over 1800ms ## Percentile Selector [#percentile-selector] Choose which percentile to view (p50, p75, p90, p99). The p75 is commonly used for Core Web Vitals assessment. ## Comparison [#comparison] Each metric shows the change from the previous period, with green indicating improvement and red indicating degradation. ## Breakdowns [#breakdowns] * **By Page** - Find your slowest pages * **By Device** - Compare mobile vs desktop performance * **By Browser** - Identify browser-specific issues * **By Country** - See geographic performance differences # Retention (/docs/feature-guides/retention) <img alt="Retention Dashboard" src="__img0" /> Retention analysis shows how many users come back to your site over time, revealing whether users find lasting value. ## Understanding Cohorts [#understanding-cohorts] A cohort is a group of users who share a common start date. For example, all users who first visited on January 15th form one cohort. ## Reading the Retention Table [#reading-the-retention-table] ### Rows [#rows] Each row is a different cohort, grouped by first visit date. ### Columns [#columns] * **Day/Week 0** - Starting point (always 100%) * **Day/Week 1, 2, 3...** - Users who returned after that period ### Numbers [#numbers] Percentages show what portion of the original cohort returned. 40% in the "Day 7" column means 40% of users came back after a week. ### Color Coding [#color-coding] * Lighter colors = higher retention * Darker colors = more drop-off ## Key Insights [#key-insights] * **Day 1 retention** - Did users find enough value to return the next day? * **Day 7 retention** - Did users integrate your product into their routine? * **Day 30 retention** - Have users formed a habit? ## Common Patterns [#common-patterns] * **Cliff drop** - Sharp decline in first few days indicates onboarding issues * **Steady decline** - Gradual drop suggests novelty wearing off * **Plateau** - When retention levels off, you've found your core users ## Note on User ID Salting [#note-on-user-id-salting] If [User ID Salting](/docs/site-settings) is enabled, retention beyond 24 hours becomes less accurate since user identifiers reset daily. # Session Replay (/docs/feature-guides/session-replay) <img alt="Session Replay" src="__img0" /> Session replay lets you watch anonymized recordings of real user sessions, showing mouse movements, clicks, scrolls, and page navigation. ## Enabling Session Replay [#enabling-session-replay] Enable session replay in your [site settings](/docs/site-settings). The tracking script picks the setting up automatically — no script tag changes are needed. (Script-tag attributes like `data-replay-batch-size` and the `data-replay-mask-*` options only tune replay behavior; the on/off switch lives in site settings.) ## What You'll See [#what-youll-see] * Mouse movements and clicks * Scrolling behavior * Page navigation * Form interactions (with PII masked) * User details: country, device, browser ## Using Session Replay [#using-session-replay] * **UX optimization** - Identify where users get stuck or confused * **Debug issues** - See exactly what happened when users report problems * **Understand behavior** - Go beyond metrics to see the full user story * **Share with your team** - Collaborate on findings ## Privacy [#privacy] Session replay automatically masks sensitive information. Enable PII protection in your settings for additional masking of form inputs and text content. # Sessions (/docs/feature-guides/sessions) The Sessions tab shows individual user visits to your website, including all page views, interactions, and events during each session. ## Session Metrics [#session-metrics] Each session displays: * **Duration** - Time spent on site * **Pages Visited** - Number of pages viewed * **Events Triggered** - Custom events fired during the session * **Device & Browser** - Technical environment * **Location** - Geographic location based on IP * **Entry/Exit Page** - Where the session started and ended * **Bounce Status** - Whether the user left after one page ## Filtering Sessions [#filtering-sessions] Filter by: * Duration, pages visited, or events triggered * Device type, browser, or location * Referrer or traffic source * Date range ## Session Replay [#session-replay] If [session replay](/docs/product-analytics/replay-session) is enabled, you can watch a recording of any session to see exactly what the user did. # Users (/docs/feature-guides/users) The Users tab shows unique visitors and their behavior across multiple sessions. ## User Metrics [#user-metrics] For each user, you'll see: * **User ID** - Anonymized identifier * **First/Last Visit** - When they first and most recently visited * **Total Sessions** - Number of separate visits * **Pages Viewed** - Total pages across all sessions * **Events Triggered** - Total custom events * **Device Type** - Desktop, mobile, or tablet * **Location** - Geographic location ## Filtering Users [#filtering-users] Filter by the standard dimension filters: * Device type, browser, or operating system * Country, region, or city * Referrer, channel, or UTM parameters * Pages visited (pathname, entry, or exit page) Session count, pageviews, events, and first/last seen aren't filters, but you can sort the user list by those columns. ## Identified Users [#identified-users] If you use the [identify API](/docs/identify-users), you can see your own user IDs alongside the anonymous identifiers. # Claude Code (/docs/mcp/claude-code) Connecting Claude Code to Rybbit lets it answer analytics questions mid-task — whether the error it just fixed still occurs in production, which pages get traffic before a refactor, whether a newly instrumented event is firing — and wire up goals and funnels as you ship features. This page covers client setup only. The [MCP overview](/docs/mcp) covers the endpoint, API keys and scopes, the full tool list, and troubleshooting. ## Connect with OAuth [#connect-with-oauth] The simplest way to connect — no API key to create or store: ```bash claude mcp add --transport http rybbit https://app.rybbit.io/api/mcp ``` For a self-hosted installation, replace the host with your configured `BASE_URL`. Then run `/mcp` inside a Claude Code session, select **rybbit**, and choose **Authenticate**. Your browser opens to log in to Rybbit and approve access, and the connection acts with your user's role. By default the server registers at the `local` scope — available to you in the current project only. Add `--scope user` to use it in all of your projects. ## Team setup [#team-setup] OAuth makes the team setup trivial: commit a project-scoped `.mcp.json` at the repository root containing only the URL, and each developer authenticates once with `/mcp`. No secret ever touches the repo: ```json { "mcpServers": { "rybbit": { "type": "http", "url": "https://app.rybbit.io/api/mcp" } } } ``` ## Connect with an API key [#connect-with-an-api-key] Use an [API key](/docs/mcp#1-create-an-api-key) instead when there is no browser to authenticate in (CI, headless `claude -p` runs) or when you want to hand the agent a narrower, scoped credential: ```bash claude mcp add --transport http rybbit https://app.rybbit.io/api/mcp \ --header "Authorization: Bearer <RYBBIT_API_KEY>" ``` In a committed `.mcp.json`, pass the key through an environment variable rather than hard-coding it — Claude Code expands `${VAR}` references from each developer's environment: ```json { "mcpServers": { "rybbit": { "type": "http", "url": "https://app.rybbit.io/api/mcp", "headers": { "Authorization": "Bearer ${RYBBIT_API_KEY}" } } } } ``` ## Verify [#verify] Run `claude mcp list` (or `/mcp` inside a session) to confirm the server shows as connected, then try: > List my Rybbit sites and summarize last week's traffic for each. # Claude Desktop (/docs/mcp/claude-desktop) Adding Rybbit as a custom connector turns Claude into a conversational analytics interface: ask how a launch went, why traffic spiked on Tuesday, or where a funnel drops off — without opening the dashboard. Connectors are shared across claude.ai, Claude Desktop, and the mobile apps. This page covers client setup only. The [MCP overview](/docs/mcp) covers the endpoint, the full tool list, permissions, and troubleshooting. ## Add the connector [#add-the-connector] 1. Open **Settings → Connectors** (in Claude Desktop: `Cmd/Ctrl + ,`, then **Connectors**; on claude.ai: your profile menu → **Settings** → **Connectors**). 2. Click **Add custom connector**. 3. Name it **Rybbit** and paste the endpoint URL: ```text https://app.rybbit.io/api/mcp ``` For a self-hosted installation, use your configured `BASE_URL` host instead. 4. Confirm, then click **Connect**. Your browser opens Rybbit's [OAuth flow](/docs/mcp#or-connect-with-oauth) — log in and approve access. The connector acts with your user's role. There is no API key to manage: custom connectors authenticate through OAuth, and the **Advanced settings** fields (OAuth client ID and secret) can stay empty — Rybbit registers the client automatically. Custom connectors are available on all Claude plans; the Free plan is limited to one. <Callout type="warn"> **Self-hosted installations must be publicly reachable.** Claude connects to custom connectors from Anthropic's infrastructure, not from your device — a Rybbit instance on a private network, behind a VPN, or on `localhost` will not connect, even if you can reach it in your own browser. </Callout> ## Use it [#use-it] Once connected, Rybbit appears in the chat's tools menu — enable it there if it isn't already. Then try: > List my Rybbit sites and summarize last month's traffic for the main one. Claude asks for confirmation before tool calls by default; destructive tools (deleting goals, funnels, sites) are marked so it always confirms those. # Codex (/docs/mcp/codex) Connecting Codex to Rybbit lets it check analytics as part of your development workflow — verifying that a fixed error stopped occurring, checking which routes get traffic before a refactor, or creating goals and funnels for features as you ship them. This page covers client setup only. The [MCP overview](/docs/mcp) covers the endpoint, API keys and scopes, the full tool list, and troubleshooting. ## Connect with OAuth [#connect-with-oauth] The simplest way to connect — add the server and log in: ```bash codex mcp add rybbit --url https://app.rybbit.io/api/mcp codex mcp login rybbit ``` For a self-hosted installation, replace the host with your configured `BASE_URL`. `codex mcp login` opens your browser to log in to Rybbit and approve access; the connection acts with your user's role. ## Connect with an API key [#connect-with-an-api-key] Use an [API key](/docs/mcp#1-create-an-api-key) instead for non-interactive runs or a narrower, scoped credential. Register the name of an environment variable holding the key: ```bash codex mcp add rybbit --url https://app.rybbit.io/api/mcp \ --bearer-token-env-var RYBBIT_API_KEY ``` Codex reads the key from that variable rather than storing it, so `RYBBIT_API_KEY` must be exported in the shell that launches Codex. Equivalently, configure it in `~/.codex/config.toml`: ```toml [mcp_servers.rybbit] url = "https://app.rybbit.io/api/mcp" bearer_token_env_var = "RYBBIT_API_KEY" ``` ## Verify [#verify] Run `codex mcp list` (or `/mcp` inside the Codex TUI) to confirm the server is connected, then try: > List my Rybbit sites and summarize last week's traffic for each. # Cursor (/docs/mcp/cursor) Connecting Cursor to Rybbit lets its agent check analytics while you code — confirming an error fix landed in production, checking traffic to a route before deleting it, or setting up goals and funnels for a new feature. This page covers client setup only. The [MCP overview](/docs/mcp) covers the endpoint, API keys and scopes, the full tool list, and troubleshooting. ## Add the server [#add-the-server] 1. [Create an API key](/docs/mcp#1-create-an-api-key) in Rybbit. 2. Add the server to `~/.cursor/mcp.json` (global — available in all projects) or `.cursor/mcp.json` in a project: ```json { "mcpServers": { "rybbit": { "url": "https://app.rybbit.io/api/mcp", "headers": { "Authorization": "Bearer <RYBBIT_API_KEY>" } } } } ``` For a self-hosted installation, replace the host with your configured `BASE_URL`. Prefer the global file for a personal key. If you configure the server in a project's `.cursor/mcp.json`, don't commit the key — give each developer their own, ideally [scoped](/docs/mcp#1-create-an-api-key) to what the agent needs. ## Verify [#verify] Open **Cursor Settings → MCP**: the server should show as connected with its tool list, and can be toggled on and off there. Then ask the agent: > List my Rybbit sites and summarize last week's traffic for each. # MCP (/docs/mcp) Rybbit includes a hosted [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) endpoint. It gives an AI assistant read and write access to your analytics: exploring traffic and behavior data, and managing sites, goals, funnels, organization members, teams, and user profiles — through the same API the dashboard uses, with the same access checks, roles, and rate limits. Your MCP client connects to Rybbit over HTTPS and sends your API key with each protocol request. Rybbit verifies the key before processing any message, and every tool call is authorized against the key user's role and site access. ## Connect a client [#connect-a-client] If your client supports the MCP authorization flow — Claude Code, Codex, Claude Desktop, and opencode all do — you can [connect with OAuth](#or-connect-with-oauth) and skip API keys entirely. The steps below describe the API-key path, which works in every client and supports scoped credentials. ### 1. Create an API key [#1-create-an-api-key] 1. Open **Settings → Account** in Rybbit. 2. Create a key in the **Personal API Keys** section. 3. Copy it immediately. Rybbit only shows a new key once. A key created without permissions can do everything its user can do. To hand an agent a narrower credential, create the key with scoped permissions (`POST /api/user/api-keys` with a `permissions` object, e.g. `{"analytics": ["read"], "goals": ["read", "write"]}`): the MCP tool list and every API call are then limited to those `resource:action` scopes. `write` implies `read` on the same resource, and scopes never elevate — org admin/owner requirements still apply. Deleting the key revokes the connection. ### 2. Add the endpoint [#2-add-the-endpoint] Use this Streamable HTTP URL for Rybbit Cloud: ```text https://app.rybbit.io/api/mcp ``` For a self-hosted installation, replace the host with your configured `BASE_URL`: ```text https://analytics.example.com/api/mcp ``` Configure your client to send this header on every request: ```text Authorization: Bearer <RYBBIT_API_KEY> ``` MCP clients use different configuration formats. A typical remote-server configuration has this shape: ```json { "mcpServers": { "rybbit": { "url": "https://app.rybbit.io/api/mcp", "headers": { "Authorization": "Bearer <RYBBIT_API_KEY>" } } } } ``` Use your client's secret or environment-variable support when available instead of saving the key in a file. Query-string API keys are not accepted by the MCP endpoint. Per-client walkthroughs — Claude Code, Codex, Claude Desktop, Cursor, VS Code, opencode — are in [Client guides](#client-guides) below. ### Or connect with OAuth [#or-connect-with-oauth] Clients that support the MCP authorization flow can skip API keys entirely: add the endpoint URL with no headers, and the client discovers Rybbit's authorization server through the standard well-known metadata (`/.well-known/oauth-protected-resource`), registers itself, and opens a browser window for you to log in and approve access. The resulting access token acts with your user's role, exactly like an API key would. OAuth grants support the same `resource:action` scopes as API keys (advertised in `scopes_supported`). A grant that requests only the standard OIDC scopes is unrestricted; a grant that requests custom scopes like `analytics:read` is limited to them. The requesting client controls which scopes it asks for — consent is currently approve-or-deny as a whole. ### 3. Start with context [#3-start-with-context] Ask the assistant to call `list_sites`, or prompt it with something like: > List my Rybbit sites, summarize last month's traffic for the docs site, and create a goal for signups. `list_sites` returns the numeric `site_id` and `organization_id` required by the other tools, plus the key's role in each organization. It does not return API keys or member email addresses. ## Client guides [#client-guides] Coding agents can use the endpoint to pull analytics into your development workflow — checking whether an error you just fixed still occurs in production, or looking up which pages matter before a refactor — and chat clients like Claude Desktop turn it into a conversational analytics interface. Follow the guide for your client: <Cards> <Card icon="<SiClaude />" title="Claude Code" description="One command with OAuth or an API key, plus a no-secrets team setup" href="/docs/mcp/claude-code" /> <Card title="Codex" description="codex mcp add, then OAuth login or a key from the environment" href="/docs/mcp/codex" /> <Card icon="<SiClaude />" title="Claude Desktop" description="Add a custom connector on claude.ai, desktop, and mobile" href="/docs/mcp/claude-desktop" /> <Card title="Cursor" description="mcp.json with an API key header" href="/docs/mcp/cursor" /> <Card title="VS Code" description="Copilot agent mode, with the key kept out of the config file" href="/docs/mcp/vscode" /> <Card title="opencode" description="Remote server config with OAuth login or env-based headers" href="/docs/mcp/opencode" /> </Cards> Any other MCP client that supports Streamable HTTP works with the generic configuration from [step 2](#2-add-the-endpoint). ## Available tools [#available-tools] ### Analytics (read) [#analytics-read] | Tool | Returns | | ------------------------- | -------------------------------------------------------------------------------------- | | `get_overview` | Sessions, pageviews, users, pages per session, bounce rate, and session duration | | `get_overview_timeseries` | The overview KPIs bucketed over time | | `get_breakdown` | Sessions broken down by one dimension (pages, referrers, countries, devices, UTM, ...) | | `get_live_stats` | Visitors active on the site right now | | `get_event_names` | Custom event names tracked on the site with counts | | `get_errors` | JavaScript errors grouped by name/message with occurrence counts | | `get_web_vitals` | Core Web Vitals percentiles (LCP, CLS, INP, FCP, TTFB) | | `get_retention` | User retention cohort table | | `get_journeys` | Most common page-to-page navigation paths | | `get_annotations` | Notes pinned to dates on the traffic chart (launches, deploys, campaigns, outages) | | `list_segments` | Saved segments: named, reusable filter sets for a site or the whole organization | | `apply_segment` | Overview KPIs for visitors matching a saved segment, with its filters for reuse | ### Sites [#sites] | Tool | Does | | -------------------- | -------------------------------------------------------------------------------------- | | `list_sites` | List organizations and sites the key can access (call this first) | | `get_site` | One site's full configuration | | `create_site` | Add a new site to an organization *(admin)* | | `update_site_config` | Change site settings: name, domain, tracking features, exclusions, tags *(admin)* | | `delete_site` | Permanently delete a site and its data *(admin, destructive)* | ### Goals & funnels [#goals--funnels] | Tool | Does | | ---------------- | ------------------------------------------------------ | | `get_goals` | Conversion goals with conversion stats | | `create_goal` | Create a path, event, or autocapture goal | | `update_goal` | Replace a goal's definition | | `delete_goal` | Permanently delete a goal *(destructive)* | | `get_funnels` | Saved funnel definitions | | `analyze_funnel` | Compute an ad-hoc funnel without saving it | | `save_funnel` | Save a funnel, or update one by passing `funnel_id` | | `delete_funnel` | Permanently delete a saved funnel *(destructive)* | ### People [#people] | Tool | Does | | -------------------- | --------------------------------------------------------------------------- | | `get_users` | Person inventory with per-user aggregates and traits (sortable, searchable) | | `get_user` | One person's profile: traits, linked devices, vitals, locations | | `identify_user` | Link an anonymous device to your user ID and merge traits | | `update_user_traits` | Replace a person's traits wholesale | | `delete_user` | GDPR erasure of one person's analytics data *(admin, destructive)* | ### Organization & teams [#organization--teams] | Tool | Does | | --------------------------- | -------------------------------------------------------------- | | `list_members` | Organization members with roles, site access, and teams | | `add_member` | Add an existing Rybbit user to the organization *(admin)* | | `update_member_site_access` | Restrict a member to specific sites *(admin)* | | `list_teams` | Teams with members and site access | | `create_team` | Create a team *(admin)* | | `update_team` | Rename a team or replace its members/sites *(admin)* | | `delete_team` | Permanently delete a team *(admin, destructive)* | ### Raw data & SQL [#raw-data--sql] | Tool | Does | | ------------------ | ---------------------------------------------------------------------- | | `get_sessions` | Recent visitor sessions with full attribution | | `get_session` | One session's detail plus its event timeline | | `get_events` | Raw recent events, newest first | | `get_query_schema` | The ClickHouse schema and rules for `run_query` | | `run_query` | Read-only ClickHouse SQL against the site-scoped `scoped_events` table | Analytics tools accept optional time inputs (`start_date`/`end_date` with an IANA `time_zone`, or `past_minutes`) and the same filters as the dashboard. Omitting time inputs queries all time; an unrecognized `time_zone` is rejected before the call is made. `get_sessions`, `get_events`, and `get_users` accept an optional `fields` list to return only the named row columns, which keeps large result sets small. ## Permissions and roles [#permissions-and-roles] The MCP surface mirrors the REST API's permission model: * **Read tools** work for any member with access to the site. * **Write tools** need at least member-level site access (goals, funnels, `identify_user`, `update_user_traits`, `save_funnel`). * **Tools marked *(admin)*** require the API key's user to hold the **admin or owner** role in the organization; `add_member` can grant the `owner` role only from an owner's key. The REST layer enforces all of this — an underprivileged key gets a 403 with an explanatory message. To give an assistant a read-only or read-mostly view, create a **scoped** API key (see step 1): grant only the `resource:action` scopes it needs — e.g. `{"analytics": ["read"], "sessions": ["read"]}` for a reporting agent. The MCP tool list is filtered to those scopes, and any out-of-scope call returns `403 { "error": "Insufficient scope", "required": "goals:write" }`. Alternatively, connecting with a member-role user's key hides nothing but refuses every call requiring the admin/owner role. OAuth clients request scopes through the standard grant. Rybbit advertises the same `resource:action` scopes in its discovery metadata; a grant carrying no custom scopes (or only the standard OIDC scopes) is treated as full access, for backward compatibility. ## Destructive tools [#destructive-tools] `delete_goal`, `delete_funnel`, `delete_site`, `delete_team`, and `delete_user` permanently destroy data and are marked with the MCP `destructiveHint` annotation, so well-behaved clients ask for confirmation before invoking them. `delete_site` and `delete_user` (the GDPR-erasure path) also remove recorded events and replays, with erasure completing asynchronously. ## Data and security boundary [#data-and-security-boundary] * The API key is verified before any MCP message is processed, and each tool call re-runs the corresponding REST route's own access checks, role requirements, and rate limits. * The surface returns whatever the REST API returns for your role — including session-level records and, where a site has IP tracking enabled, visitor IP addresses. Connect only AI clients you trust with that data, and use a separate, revocable API key per integration. * Text originating in tracked traffic (page titles, paths, referrers, event names, traits) is stripped of control and bidi-override characters. AI clients should still treat these values as untrusted data, never as instructions. * `run_query` is restricted to a read-only, site-scoped table with row and execution-time caps. * The server does not contain an AI model or send analytics to an AI provider. Data only leaves Rybbit in response to the MCP client you configure. * The endpoint is stateless. Each HTTP request is independently authenticated. ## Troubleshooting [#troubleshooting] * **401 Unauthorized:** The client did not send a Bearer credential, or the key/token is invalid, expired, or revoked. OAuth-capable clients re-authenticate automatically via the `WWW-Authenticate` challenge. * **OAuth login loop or "table does not exist" (self-hosted):** Run `npm run db:push` in `server/` to create the OAuth tables, then retry. * **403 in tool results:** The key's user lacks access to that site or organization — or the tool requires an org admin/owner role. Check the `role` field in `list_sites`. * **403 "Insufficient scope":** The credential was created without the `resource:action` scope this tool needs (the `required` field names it). Use a key or OAuth grant that includes it. * **429 Too Many Requests:** The API key exceeded its plan's rate limit. * **503 Service Unavailable:** Rybbit could not verify the key. Retry the request; do not replace a known-valid key based on this response. * **405 Method Not Allowed on GET:** This is expected. The stateless endpoint accepts MCP messages over `POST`. * **Site access error:** Call `list_sites` again. Membership or site permissions may have changed. # opencode (/docs/mcp/opencode) Connecting opencode to Rybbit lets it pull analytics into your terminal workflow — verifying an error fix in production, checking a route's traffic before removing it, or wiring up goals and funnels as you ship. This page covers client setup only. The [MCP overview](/docs/mcp) covers the endpoint, API keys and scopes, the full tool list, and troubleshooting. ## Connect with OAuth [#connect-with-oauth] Add the server to `opencode.json` at your project root (or your global opencode config): ```json { "$schema": "https://opencode.ai/config.json", "mcp": { "rybbit": { "type": "remote", "url": "https://app.rybbit.io/api/mcp", "enabled": true } } } ``` For a self-hosted installation, replace the host with your configured `BASE_URL`. Then authenticate: ```bash opencode mcp auth rybbit ``` Your browser opens Rybbit's [OAuth flow](/docs/mcp#or-connect-with-oauth) — log in and approve access. The connection acts with your user's role, and the committed config carries no secret. `opencode mcp logout rybbit` removes the stored credential. ## Connect with an API key [#connect-with-an-api-key] To use an [API key](/docs/mcp#1-create-an-api-key) instead — for non-interactive runs or a narrower, scoped credential — add a headers block that reads the key from the environment, so the config stays committable: ```json { "$schema": "https://opencode.ai/config.json", "mcp": { "rybbit": { "type": "remote", "url": "https://app.rybbit.io/api/mcp", "enabled": true, "headers": { "Authorization": "Bearer {env:RYBBIT_API_KEY}" } } } } ``` ## Verify [#verify] Run `opencode mcp list` to see the server and its authentication status (`opencode mcp debug rybbit` gives detailed diagnostics), then try: > List my Rybbit sites and summarize last week's traffic for each. # VS Code (/docs/mcp/vscode) Connecting VS Code to Rybbit gives Copilot's agent mode access to your analytics — checking whether a fixed error still occurs, looking up which pages matter before a refactor, or creating goals for a feature as you build it. This page covers client setup only. The [MCP overview](/docs/mcp) covers the endpoint, API keys and scopes, the full tool list, and troubleshooting. ## Add the server [#add-the-server] 1. [Create an API key](/docs/mcp#1-create-an-api-key) in Rybbit. 2. Create `.vscode/mcp.json` in your workspace. The `inputs` block keeps the key out of the file — VS Code prompts for it once when the server first starts and stores it securely, so this file is safe to commit: ```json { "inputs": [ { "type": "promptString", "id": "rybbit-api-key", "description": "Rybbit API key", "password": true } ], "servers": { "rybbit": { "type": "http", "url": "https://app.rybbit.io/api/mcp", "headers": { "Authorization": "Bearer ${input:rybbit-api-key}" } } } } ``` For a self-hosted installation, replace the host with your configured `BASE_URL`. To make the server available in every workspace instead, run **MCP: Open User Configuration** from the Command Palette and add the same JSON there. **MCP: Add Server** offers a guided setup as an alternative to editing the file. ## Verify [#verify] Run **MCP: List Servers** from the Command Palette — Rybbit should be listed with its tools, which also appear in the tools picker in Copilot's agent mode. Then ask the agent: > List my Rybbit sites and summarize last week's traffic for each. # Angular (/docs/guides/angular) An Angular CLI project serves one HTML shell, `src/index.html`, for every route. Paste the snippet into its `<head>` and the script loads once for the whole app. <Steps> <Step> ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html <script src="https://app.rybbit.io/api/script.js?siteId=YOUR_SITE_ID" defer></script> ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. </Step> <Step> ### Add the snippet to Angular [#add-the-snippet-to-angular] Open `src/index.html` and paste the snippet just before `</head>`: ```html title="src/index.html" <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>MyAngularApp ``` Placing it before `` also works; `` lets it start loading sooner. Navigation through the Angular Router is tracked automatically as pageviews. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Track custom events [#track-custom-events] Declare `rybbit` on `window` once so TypeScript accepts the call, then call `window.rybbit.event()` from any component or service. Guard the call so a blocked script does not throw. ```typescript title="src/app/feature/feature.component.ts" import { Component } from "@angular/core"; declare global { interface Window { rybbit?: { event: (eventName: string, eventData?: Record) => void; pageview: () => void; }; } } @Component({ selector: "app-feature", template: ``, }) export class FeatureComponent { onFeatureClick(): void { if (window.rybbit) { window.rybbit.event("feature_used", { featureName: "Amazing Feature" }); } } } ``` ## Troubleshooting [#troubleshooting] * **Pageviews missing after navigation**: this is rare. If it happens, subscribe to `Router.events`, filter for `NavigationEnd` and call `window.rybbit.pageview()`; test the default behaviour first. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # ASP.NET Core (/docs/guides/aspnet) ASP.NET Core MVC and Razor Pages apps share one layout file, `_Layout.cshtml`, that wraps every view, so the snippet goes into its ``. Keep the site ID in `appsettings.json` and inject `IConfiguration` into the layout so nothing is hard-coded. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to ASP.NET Core [#add-the-snippet-to-aspnet-core] #### Configure the site ID [#configure-the-site-id] Add a `Rybbit` section to `appsettings.json`: ```json filename="appsettings.json" { "Rybbit": { "InstanceUrl": "https://app.rybbit.io", "SiteId": "YOUR_SITE_ID" } } ``` To keep the ID out of source control or vary it per environment, leave `SiteId` empty in `appsettings.json` and set it in `appsettings.Production.json`, or as the environment variable `Rybbit__SiteId` (double underscore separates configuration sections). #### Add the tag to your layout [#add-the-tag-to-your-layout] Open the layout file: * Controllers with views: `Views/Shared/_Layout.cshtml` * Razor Pages: `Pages/Shared/_Layout.cshtml` Inject `IConfiguration` at the top and add the tag inside ``: ```razor filename="Views/Shared/_Layout.cshtml" @inject IConfiguration Configuration @{ var rybbitSiteId = Configuration["Rybbit:SiteId"]; var rybbitInstanceUrl = Configuration["Rybbit:InstanceUrl"] ?? "https://app.rybbit.io"; } @ViewData["Title"] - MyApp @if (!string.IsNullOrEmpty(rybbitSiteId)) { } ``` If `Microsoft.Extensions.Configuration` is not already imported in `_ViewImports.cshtml`, add `@using Microsoft.Extensions.Configuration` above the `@inject` line. The `@if` guard skips the tag when `SiteId` is empty, so development is not tracked unless you set the value there. If your app has more than one layout, add the tag to each, or move it into a partial view and render it with `` from every layout. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Tag missing**: environment-specific files such as `appsettings.Development.json` override `appsettings.json`, so check which environment you are running (`ASPNETCORE_ENVIRONMENT`) and that the `Rybbit` section exists there or in the base file. Environment variables override both. * **Content Security Policy**: if middleware adds a `Content-Security-Policy` header, add your Rybbit host to `script-src` and `connect-src`, or [proxy the script](/docs/proxy-guide) through your own domain. * **Response caching or output caching**: the tag is rendered server-side, so a cached response keeps whatever configuration was active when it was cached. Clear the cache after changing the site ID. * **Self-hosted instance**: set `Rybbit:InstanceUrl` to your own domain without a trailing slash; the layout appends the script path to it. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Astro (/docs/guides/astro) Astro pages share a layout component that renders the `` document. Add the snippet to the `` of that layout and every page that uses it is tracked. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Astro [#add-the-snippet-to-astro] Open your shared layout, usually `src/layouts/Layout.astro`, and paste the snippet inside ``. Add `is:inline` so Astro leaves the tag as-is instead of bundling it: ```astro title="src/layouts/Layout.astro" --- const { title } = Astro.props; --- {title} ``` If you do not use a shared layout, add the same tag to the `` of each page in `src/pages/`. A layout is easier to maintain. Astro builds a multi-page site by default, so each navigation is a full page load and a new pageview. With [view transitions](https://docs.astro.build/en/guides/view-transitions/) enabled, client-side navigation is tracked automatically when the URL changes. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Track custom events [#track-custom-events] Call `window.rybbit.event()` from a client-side ` ``` ## Troubleshooting [#troubleshooting] * **Pageviews missing with view transitions**: this is rare. If it happens, listen for Astro's `astro:after-swap` event in a client-side script and call `window.rybbit.pageview()`; test the default behaviour first. * **Proxying through your own domain**: Astro can serve the script from your domain with SSR middleware. See the [Astro proxy guide](/docs/proxy-guide/astro). ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # BigCommerce (/docs/guides/bigcommerce) BigCommerce's **Script Manager** adds scripts to every storefront page without theme edits, and it is also where the order-confirmation purchase event goes. Editing the theme's `head.html` works too. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to BigCommerce [#add-the-snippet-to-bigcommerce] 1. In the control panel, go to **Storefront > Script Manager** and click **Create a Script**. 2. Fill in the settings: * **Name**: Rybbit Analytics * **Placement**: Head * **Location**: All pages * **Script category**: Analytics * **Script type**: Script 3. Paste the snippet into **Script contents** and click **Save**. Back up your theme before editing files. 1. Go to **Storefront > Themes**, click **Advanced** on your current theme, then **Edit Theme Files**. 2. Open **templates > components > common** and then `head.html`. 3. Paste the snippet at the end of the file, before the closing `` tag, and click **Save**. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Track custom events [#track-custom-events] #### Purchase [#purchase] Create a second script in Script Manager with **Placement: Footer**, **Location: Order Confirmation** and **Script type: Script**. BigCommerce runs Script Manager contents through Handlebars, so expressions such as `checkout.order.id` are filled in with the order data before the page is served: ```html ``` `waitForRybbit` polls until the tracking script from the head has defined `window.rybbit`, so the event is not lost if the confirmation page runs the footer script first. ## Troubleshooting [#troubleshooting] * **Script not on the page**: confirm the script is enabled in Script Manager, then purge the store cache under **Server Settings > Store-level caching > Purge cache**. * **Purchase event missing**: the purchase script must use the **Order Confirmation** location, and the `checkout.order`, `checkout.subtotal` and `checkout.currency` Handlebars objects depend on your BigCommerce checkout version. Place a test order to confirm the event arrives. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Blazor (/docs/guides/blazor) Where the snippet goes depends on the Blazor hosting model: a Blazor Web App keeps its `` in `Components/App.razor`, a standalone Blazor WebAssembly app in `wwwroot/index.html`, and a Blazor Server app (.NET 7 and earlier) in `Pages/_Host.cshtml`. In each case the tag is added once and stays on the page while Blazor handles navigation client-side. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Blazor [#add-the-snippet-to-blazor] `App.razor` is rendered on the server, so it can read the site ID from `appsettings.json`. Add a `Rybbit` section: ```json filename="appsettings.json" { "Rybbit": { "InstanceUrl": "https://app.rybbit.io", "SiteId": "YOUR_SITE_ID" } } ``` Then inject `IConfiguration` in `Components/App.razor` and add the tag inside ``: ```razor filename="Components/App.razor" @inject IConfiguration Configuration @{ var rybbitSiteId = Configuration["Rybbit:SiteId"]; var rybbitInstanceUrl = Configuration["Rybbit:InstanceUrl"] ?? "https://app.rybbit.io"; } @if (!string.IsNullOrEmpty(rybbitSiteId)) { } ``` Override the value per environment with `appsettings.Production.json` or the environment variable `Rybbit__SiteId`. A standalone WebAssembly app ships `wwwroot/index.html` as a static file, so add the tag directly inside ``: ```html filename="wwwroot/index.html" ``` The site ID is public on every platform (it is in the page source), so a static value here is fine; `wwwroot/appsettings.json` is equally visible to the browser and offers no advantage for this value. Add the `Rybbit` section to `appsettings.json` as in the Blazor Web App tab, then open `Pages/_Host.cshtml` (or `Pages/_Layout.cshtml` in .NET 6 projects, where the `` lives) and add the tag: ```razor filename="Pages/_Host.cshtml" @page "/" @using Microsoft.Extensions.Configuration @inject IConfiguration Configuration @{ var rybbitSiteId = Configuration["Rybbit:SiteId"]; var rybbitInstanceUrl = Configuration["Rybbit:InstanceUrl"] ?? "https://app.rybbit.io"; } @if (!string.IsNullOrEmpty(rybbitSiteId)) { } ``` #### Navigation [#navigation] Blazor's router, `NavigationManager.NavigateTo` and enhanced navigation in Blazor Web Apps all update the URL through `history.pushState` (or `replaceState` when `replace: true`) rather than reloading the page. The Rybbit script hooks `history.pushState` and `history.replaceState` and listens for `popstate` and `hashchange`, so each navigation is tracked as a pageview while **SPA Navigation** is enabled in your site settings (the default). Enhanced navigation patches the DOM without re-running scripts already in ``, which is what you want: the tracker loads once and keeps running. `NavigateTo(url, forceLoad: true)` and links with `data-enhance-nav="false"` do a full page load, which the script tracks as a new pageview. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Tag missing in a Blazor Web App**: `App.razor` reads server-side configuration, so check the `appsettings` file for the active `ASPNETCORE_ENVIRONMENT`; environment variables override the files. * **Blazor WebAssembly PWA**: `index.html` is cached by the service worker. Users receive the new tag only after the app is republished and the service worker updates. * **Content Security Policy**: if you set one, add your Rybbit host to `script-src` and `connect-src`, or [proxy the script](/docs/proxy-guide) through your own domain. * **Self-hosted instance**: set `Rybbit:InstanceUrl` (or the static URL in `index.html`) to your own domain without a trailing slash. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Bolt (/docs/guides/bolt) Bolt has no settings field for custom head code (its **Analytics** project setting only shows traffic for published sites), so the snippet goes into the app's HTML head as a code change: prompt Bolt to add it, or switch to Code view and paste it into the root HTML or layout file. Publish the project again afterwards. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Bolt [#add-the-snippet-to-bolt] Paste this into the chat, with your snippet in place of the placeholder: ```text Add this script tag to the of every page and keep all existing head tags: ``` Review the change Bolt makes before you move on: the tag should be in the document head, not inside a component. Click the **Code Preview** icon (`<>`) at the top center to switch to Code view, then open the file that owns the `` for the framework Bolt chose: * **Vite** projects (React, Vue, Svelte): `index.html` at the project root. * **Next.js** projects: `app/layout.tsx`, using the `next/script` component as in the [Next.js guide](/docs/guides/react/next-js). * Any other framework: ask Bolt "which file renders the HTML head?" and edit that one. For a Vite project, paste the snippet before ``: ```html title="index.html" My app ``` Click **Save** or press **Ctrl+S**. Publish the change: open the **Publish** menu and click **Publish** the first time, or **Update** for a site that is already live. In-app navigation is tracked as pageviews automatically. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Published site unchanged**: edits are not applied to the live site until you click **Update** in the **Publish** menu. * **Bolt removed the tag**: later prompts can regenerate `index.html` or the layout. Right-click the file in the **Files** list and choose **Lock file** so Bolt leaves it alone, or add a line to the project's **Knowledge** telling it to keep the Rybbit script. * **Expo (mobile) projects**: a script tag only runs in the web build. For the native app, use the [JavaScript SDK](/docs/sdks/web) instead. * **Numbers differ from Bolt's Analytics**: Bolt's built-in counts include crawlers and bots; Rybbit filters them. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Bubble.io (/docs/guides/bubble) Bubble adds custom header code to every page of your app through the **SEO / metatags** settings tab, so the snippet goes there once and ships with your next deploy. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Bubble.io [#add-the-snippet-to-bubbleio] 1. Open your app in the Bubble editor and click the **Settings** tab in the left sidebar. 2. Open the **SEO / metatags** sub-tab. 3. Scroll to **Script/meta tags in header** and paste the snippet. It is added to the `` of every page in the app. 4. Deploy the app to **Live**. The development version does not pick up the change until you deploy. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Track custom events [#track-custom-events] Bubble workflows can run arbitrary JavaScript through a **Run JavaScript** action (provided by the free Toolbox plugin). Use it to send events from any workflow trigger. 1. Open the **Workflow** tab and pick the trigger, for example *When Button Signup is clicked*. 2. Add a **Run JavaScript** action. 3. Paste the call, using Bubble's dynamic expressions for the values: ```javascript if (window.rybbit) { window.rybbit.event("signup_clicked", { plan: "Current User's Plan", }); } ``` ## Troubleshooting [#troubleshooting] * **Only the first pageview is tracked**: Bubble apps that switch between groups on a single page do not change the URL, so there is nothing for the script to detect. Add a **Run JavaScript** action that calls `window.rybbit.pageview()` when the group becomes visible, or track those views as custom events. Apps that navigate between real Bubble pages are tracked automatically. * **Snippet missing on the live site**: the header script is versioned with your app, so check that you deployed to Live after saving the setting. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Capacitor (/docs/guides/capacitor) Capacitor wraps a normal web app in a native WebView, so the snippet goes in the `` of that web app's `index.html` and ships to iOS and Android with `npx cap sync`. If the app is built with Ionic Angular, React or Vue, follow the [Angular](/docs/guides/angular), [React](/docs/guides/react/vite-cra) or [Vue](/docs/guides/vue/vite) guide for where the tag lives, then come back here for the WebView origin settings. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Capacitor [#add-the-snippet-to-capacitor] 1. Paste the snippet into the `` of your web app's `index.html` (the file in the `webDir` folder from `capacitor.config`, after your framework's build): ```html title="index.html" ``` 2. Enable `CapacitorHttp`, which routes `window.fetch` through native HTTP. The iOS WebView runs on `capacitor://localhost`, and Rybbit's tracking endpoints answer CORS preflights for `http://` and `https://` origins only, so without this the browser blocks the requests on iOS: ```ts title="capacitor.config.ts" import type { CapacitorConfig } from "@capacitor/cli"; const config: CapacitorConfig = { appId: "com.example.app", appName: "My App", webDir: "dist", plugins: { CapacitorHttp: { enabled: true, }, }, }; export default config; ``` 3. Build the web app, then copy it into the native projects: ```bash npm run build npx cap sync ``` Android serves the app from `https://localhost` (`server.androidScheme` defaults to `https`), which is a regular origin, so tracking works there with or without `CapacitorHttp`. In-app route changes are tracked automatically: the tracker wraps `history.pushState` and `history.replaceState` and listens for `popstate` and `hashchange`. Use the script tag rather than the `@rybbit/js` npm package on Capacitor. The package sends events with `navigator.sendBeacon`, which `CapacitorHttp` does not patch, so its requests from `capacitor://localhost` are still blocked on iOS. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Works on Android and in the browser, silent on iOS**: `CapacitorHttp` is not enabled, or the native projects were not re-synced after changing the config. Run `npx cap sync` and rebuild in Xcode. * **Changes do not appear on device**: `npx cap sync` copies the built web bundle; edit the source, rebuild the web app, then sync again. * **Live reload during development**: with `server.url` pointing at your dev server the WebView loads an `http://` origin, so requests succeed even without `CapacitorHttp`. Do not read a working dev build as proof that the production build is configured. * **Every pageview shows `localhost` as hostname**: expected. `server.hostname` defaults to `localhost` on both platforms; filter by site, not hostname. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Carrd (/docs/guides/carrd) Carrd builds one-page sites, and the snippet goes into the **Head** embed code field in the site settings. Custom head code requires a **Carrd Pro** subscription (Pro Standard or Pro Plus). The Embed Code feature is not included on Pro Lite. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Carrd [#add-the-snippet-to-carrd] 1. Open your site in the Carrd editor and click the **Settings** gear in the top right. 2. Open the **Settings** tab and scroll to **Embed Code**. 3. Paste the following into the **Head** field: ```html ``` This wraps the snippet in a small loader so Carrd's embed processing cannot rewrite the tag. 4. Click **Save**, then **Publish**. Changes are not live until you publish. If your site has multiple pages (a Pro Plus feature), the head code loads on all of them. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **The Head field is missing**: the site is on Pro Lite or the free plan. Upgrade to Pro Standard or Pro Plus. * **Snippet not in the page source**: the site was saved but not published. Publish again from the editor. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Chrome extension (/docs/guides/chrome-extension) Manifest V3 requires every piece of executable code to ship inside the extension package, and the `extension_pages` Content Security Policy is fixed at `script-src 'self' 'wasm-unsafe-eval'; object-src 'self'` with no way to relax it. A remote ` ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. You only need the site ID from the snippet; the script tag itself is not used in an extension. ### Add the snippet to Chrome extension [#add-the-snippet-to-chrome-extension] 1. Install the SDK in the extension project: ```bash npm install @rybbit/js ``` 2. Initialise it in the entry script of each extension page you want to track (the popup from `action.default_popup`, the options page from `options_ui.page`, the side panel from `side_panel.default_path`). The SDK needs `window` and `document`, so do not import it in the service worker. ```js title="src/popup.js" import rybbit from "@rybbit/js"; await rybbit.init({ analyticsHost: "https://app.rybbit.io/api", siteId: "YOUR_SITE_ID", }); // Extension pages have URLs like chrome-extension:///popup.html. // Report a readable path instead: rybbit.pageview("/popup"); ``` 3. Grant the extension permission to call the analytics host. Requests from extension pages to origins listed in `host_permissions` are not subject to CORS: ```json title="manifest.json" { "manifest_version": 3, "host_permissions": ["https://app.rybbit.io/*"] } ``` 4. Build the extension with your bundler (Vite, webpack, Rollup) so `@rybbit/js` ends up inside the package, then reload the unpacked extension at `chrome://extensions`. The default extension CSP does not restrict `connect-src`, so no `content_security_policy` entry is required. If you define your own policy, add `https://app.rybbit.io` to its `connect-src`. Do not initialise the SDK in a content script. Content scripts run inside third-party websites; tracking there records other people's pages under your site ID, and content scripts are subject to the host page's same-origin policy in any case. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Track custom events [#track-custom-events] Call `rybbit.event()` from the page script, or use data attributes on buttons in your popup HTML: ```js title="src/popup.js" document.getElementById("save")?.addEventListener("click", () => { rybbit.event("settings_saved", { theme: "dark" }); }); ``` ```html title="popup.html" ``` ## Troubleshooting [#troubleshooting] * **`Refused to load the script` in the popup console**: the fixed `extension_pages` CSP blocked a remote tag. Remove it and use the SDK. * **`POST /api/track` blocked by CORS**: `host_permissions` is missing or does not match. The pattern must include the path wildcard: `https://app.rybbit.io/*`. * **Self-hosted Rybbit**: replace `https://app.rybbit.io` in both `analyticsHost` and `host_permissions` with your own instance URL. * **Every pageview shows the extension ID as hostname**: expected. Extension pages live on `chrome-extension://`; use `rybbit.pageview("/name")` for readable paths. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Circle (/docs/guides/circle) Circle has a **Code snippets** setting that injects custom code into the `` of every page in your community, so the snippet goes there once and covers public pages and logged-in member pages alike. Custom CSS/JavaScript snippets are included on all current Circle plans (Professional, Business and Circle Plus). ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Circle [#add-the-snippet-to-circle] 1. Open your community as an admin and go to **Settings**. 2. Open **Code snippets**. 3. Paste the snippet into the **Head code snippets** field. 4. Click **Save**. Changes apply to the whole community immediately; there is no publish step. Circle's own help article for this setting is [Custom code snippets](https://help.circle.so/p/administration/site-management/custom-code-snippets). Circle is a single-page app: after the first load, navigating between spaces, posts and courses changes the URL without a full reload. The script tracks these client-side route changes automatically, so each space and post shows up as its own pageview. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Custom domain**: the same head code is served on `yourcommunity.circle.so` and on your custom domain. If both hostnames are in use, keep them under one site in Rybbit so sessions are not split. * **Post embeds**: custom HTML embedded inside a post is scoped to that post. Put the snippet in head code snippets, not in a post. * **Nothing tracked while logged in as admin**: Circle applies head code to admins too, so check that your own IP or user is not excluded in Rybbit's site settings before assuming the snippet is missing. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # ClickFunnels (/docs/guides/clickfunnels) ClickFunnels 2.0 has a **Header Code** field at three levels: **Site Settings** for the whole workspace, **Funnel settings** for every page in one funnel, and the page editor for a single page. Add the snippet at the widest level you want tracked and leave the narrower ones empty so it is not loaded twice. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to ClickFunnels [#add-the-snippet-to-clickfunnels] 1. In your workspace, open the **Site & Blog** app in the left menu and click **Site Settings**. 2. Paste the snippet into **Header Code**. 3. Save. This applies to every page in the workspace: funnel pages, store pages, courses, blog posts and site pages. 1. Open **Funnels** and click the **Settings** gear next to the funnel (or the **Settings** button in the top-right of the funnel workflow editor). 2. Paste the snippet into **Head Code**. 3. Click **Update Funnel**. The code loads on every page of that funnel only. 1. Open the page in the page editor. 2. Go to **Settings > Tracking Code** and paste the snippet into **Header Code**. 3. Click **Save**. The code loads on this page only. Save, then open the live page URL to test: header code does not run inside the page editor. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Duplicate pageviews**: the snippet is present at more than one level (site and funnel, or funnel and page). Keep a single copy. * **Snippet not in the page source**: check which level you pasted it at. Site Settings covers the workspace, funnel settings cover that funnel's pages only, and the page editor covers one page. * **jQuery**: ClickFunnels pages already include jQuery, so do not load it again in the same header code block; a second copy can break page scripts. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Contentful (/docs/guides/contentful) Contentful is a headless CMS: it stores content and delivers it over its APIs, but never renders your pages. The tracking snippet therefore goes into the frontend that consumes Contentful, not into Contentful itself. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Contentful [#add-the-snippet-to-contentful] Contentful has no template or head setting to paste into. Add the snippet to the `` of the frontend application that fetches content from Contentful. Next.js is the most common Contentful frontend. Add the snippet with `next/script` in your root layout so it loads on every route. Follow the [Next.js guide](/docs/guides/react/next-js). Add the snippet under `app.head.script` in `nuxt.config.ts`. Follow the [Nuxt guide](/docs/guides/vue/nuxt). * [Gatsby](/docs/guides/react/gatsby), which pairs with the official `gatsby-source-contentful` plugin * [Remix](/docs/guides/react/remix) * [Vite + React](/docs/guides/react/vite-cra) * [Vite + Vue](/docs/guides/vue/vite) * [Astro](/docs/guides/astro) * [SvelteKit](/docs/guides/svelte/sveltekit) * [Angular](/docs/guides/angular) ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Editor previews count as visits**: Contentful's content preview URLs open a preview or draft build of your frontend. Register that deployment as a separate site in Rybbit, or leave the snippet out of preview builds, so editors' previews are not mixed into your production data. * **The Contentful web app is not tracked**: the snippet only runs on your frontend, so time spent editing in Contentful never appears in Rybbit. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Craft CMS (/docs/guides/craft-cms) Craft CMS renders pages from Twig templates in the `templates/` folder, so the snippet goes into the base template that your section templates extend. A fresh `craftcms/craft` project ships a single `templates/index.twig` containing the full HTML document; most projects move that boilerplate into a base template and `{% extends %}` it. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Craft CMS [#add-the-snippet-to-craft-cms] 1. Open the template that owns your ``. In a fresh install that is `templates/index.twig`; in a project with a base template it is the underscore-prefixed file every page extends, such as `templates/_layout.twig`. 2. Add the tag before ``: ```twig title="templates/_layout.twig" {{ siteName }} {% block content %}{% endblock %} ``` 3. Every section template that extends the base template now loads the script: ```twig title="templates/blog/_entry.twig" {% extends "_layout" %} {% block content %}

{{ entry.title }}

{{ entry.body }} {% endblock %} {% endraw %} ``` To keep the site ID out of templates and vary it per environment, read it from `.env` with `{{ getenv('RYBBIT_SITE_ID') }}`, or store it in a Global Set and use `{{ settings.rybbitSiteId }}`.
### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes.
## Troubleshooting [#troubleshooting] * **Template caching**: if you wrap the head in `{% cache %}` tags, the change appears only after the cache expires or you run `php craft clear-caches/all`. * **Live Preview counts as visits**: the control panel's Live Preview renders your template in an iframe with `x-craft-live-preview` in the query string. Wrap the tag in `{% if not craft.app.request.isPreview %}` to leave editors' previews out of your data. * **Control panel is not tracked**: `/admin` uses Craft's own templates, so the snippet never runs there. * **Multiple base templates**: if some sections extend a different base (for example `_layout-landing.twig`), add the tag to each one, or move it into a partial and `{% include %}` it from every base. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Discourse (/docs/guides/discourse) Discourse adds site-wide code through **theme components**. A component has an editable `` section that Discourse injects into every page and, because Discourse ships a strict Content Security Policy, it stamps the required nonce onto script tags placed there. No server access or plugin is needed. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Discourse [#add-the-snippet-to-discourse] 1. In the admin sidebar open **Appearance → Themes and components** (older versions: **Admin → Customize → Themes**). 2. On the **Components** tab click **Install**, choose **Create new**, name it "Rybbit Analytics", make sure the type is **Component** and click **Create**. 3. Click **Edit CSS/HTML**, stay on the **Common** tab and open the **``** section (not **Header**, which is the visible page header). 4. Paste the snippet and click **Save**. 5. Back on the component page, add your active theme under **Include component on these themes** so the component is enabled. Discourse is a single-page app: after the first load, topics and categories open without a full page reload. The tracker wraps `history.pushState` and `history.replaceState` and listens for `popstate`, so those navigations are recorded as pageviews. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Component saved but the script is missing from the page source**: the component is not attached to the active theme. Add the theme under **Include component on these themes**, or add the component from the theme's **Included components** list. * **Script tag present but blocked**: check the browser console for a CSP error. Scripts added through the `` section receive Discourse's nonce automatically; scripts injected any other way (for example from a plugin's raw HTML) do not. * **Only the first pageview is recorded**: the **SPA Navigation** setting is off for the site in your Rybbit dashboard. Turn it on under Site Settings. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Django (/docs/guides/django) Django pages extend a base template, so the snippet goes into the `` of that base template. Put the site ID in `settings.py`, read from an environment variable, and pass it to templates with a small context processor so no template hard-codes the ID. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Django [#add-the-snippet-to-django] #### Configure the site ID [#configure-the-site-id] Add the Rybbit values to `settings.py`, reading from the environment: ```python filename="settings.py" import os RYBBIT_INSTANCE_URL = os.environ.get("RYBBIT_INSTANCE_URL", "https://app.rybbit.io") RYBBIT_SITE_ID = os.environ.get("RYBBIT_SITE_ID", "") ``` Set `RYBBIT_SITE_ID=YOUR_SITE_ID` in your production environment (or your `.env` file if you load one with django-environ or python-dotenv). #### Expose the values to templates [#expose-the-values-to-templates] Create a context processor that adds both settings to every template context: ```python filename="myproject/context_processors.py" from django.conf import settings def rybbit(request): return { "RYBBIT_INSTANCE_URL": settings.RYBBIT_INSTANCE_URL, "RYBBIT_SITE_ID": settings.RYBBIT_SITE_ID, } ``` Register it under `TEMPLATES` in `settings.py`: ```python filename="settings.py" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "myproject.context_processors.rybbit", ], }, }, ] ``` #### Add the tag to your base template [#add-the-tag-to-your-base-template] Open the template that every page extends, usually `templates/base.html`, and add the tag inside ``: ```jinja filename="templates/base.html" {% block title %}My Site{% endblock %} {% if RYBBIT_SITE_ID %} {% endif %} ``` The `{% if %}` guard skips the tag when `RYBBIT_SITE_ID` is empty, so local development is not tracked unless you set the variable. Pages that do not `{% extends "base.html" %}` (for example a separate admin or landing template) need the same tag in their own ``. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Tag missing**: settings are read at startup, so restart the server (or your WSGI/ASGI workers) after changing `RYBBIT_SITE_ID`. Confirm the context processor path matches your project package name. * **Template rendered without a request**: context processors only run for templates rendered with a `RequestContext`, which `render()` and class-based views provide. `Template.render()` without a request has no `RYBBIT_SITE_ID` and the guard hides the tag. * **Content Security Policy**: if you use django-csp, add your Rybbit host to `script-src` and `connect-src`, or [proxy the script](/docs/proxy-guide) through your own domain. * **Self-hosted instance**: set `RYBBIT_INSTANCE_URL` to your own domain without a trailing slash; the template appends the script path to it. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Docusaurus (/docs/guides/docusaurus) Docusaurus has a `scripts` option in `docusaurus.config.js` that adds a ` ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Docusaurus [#add-the-snippet-to-docusaurus] Open `docusaurus.config.js` (or `docusaurus.config.ts`) at the root of your project and add the script to the `scripts` array: ```javascript filename="docusaurus.config.js" /** @type {import('@docusaurus/types').Config} */ const config = { // ... title, url, baseUrl, presets, themeConfig scripts: [ { src: "https://app.rybbit.io/api/script.js?siteId=YOUR_SITE_ID", defer: true, }, ], }; module.exports = config; ``` If you already manage tags through `headTags`, this form is equivalent: ```javascript filename="docusaurus.config.js" headTags: [ { tagName: "script", attributes: { src: "https://app.rybbit.io/api/script.js?siteId=YOUR_SITE_ID", defer: "true", }, }, ], ``` Rebuild with `npm run build` and deploy. The tag is not hot-reloaded into a running `npm run start` session until you restart it. Docusaurus builds a single-page app on React Router. Rybbit detects client-side route changes and records them as pageviews, so no extra code is needed for navigation. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Track custom events [#track-custom-events] Docusaurus pages are React, so you can fire events from a component and use it in MDX: ```jsx filename="src/components/TrackedButton.jsx" import React from "react"; export default function TrackedButton({ children, eventName, eventData }) { const handleClick = () => { window.rybbit?.event(eventName, eventData); }; return ( ); } ``` ```mdx import TrackedButton from "@site/src/components/TrackedButton"; Learn more ``` For site-wide listeners, use a [client module](https://docusaurus.io/docs/advanced/client-modules). `onRouteDidUpdate` runs after every navigation once the DOM is ready: ```javascript filename="src/client-modules/rybbit-events.js" export function onRouteDidUpdate() { document.querySelectorAll('a[target="_blank"]').forEach((link) => { link.addEventListener("click", () => { window.rybbit?.event("external_link_click", { href: link.href }); }); }); } ``` Register it in `docusaurus.config.js` under `clientModules: [require.resolve("./src/client-modules/rybbit-events.js")]`. Client modules also run during server-side rendering, so guard any `window` access. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Drupal (/docs/guides/drupal) The [Rybbit module](https://www.drupal.org/project/rybbit) on Drupal.org adds the tracking script to every page and can pass logged-in user details to Rybbit, so you enter a Site ID instead of editing your theme. It requires Drupal 10.3 or later. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Drupal [#add-the-snippet-to-drupal] 1. Install and enable the module: ```bash composer require drupal/rybbit drush en rybbit ``` 2. Go to **Configuration → Web services → Rybbit analytics** (`/admin/config/services/rybbit`). Your account needs the *administer rybbit* permission. 3. Enter your **Site ID** and save. Optional settings on the same page: * **Script URL**: defaults to Rybbit cloud. Point it at your own instance if you self-host Rybbit. * **Visibility**: restrict tracking by role or by page path, each in include or exclude mode. * **Identify logged-in users**: calls `identify()` with the user's UUID by default, and sends traits you define as key/value pairs. `username`, `name` and `email` are recognised trait names. * The module also exposes hooks for altering the sitewide and per-user tracking data from your own module. If you cannot install contributed modules, copy `core/modules/system/templates/html.html.twig` into your theme's `templates` folder and add the snippet just before ``: ```twig ``` Clear caches afterwards so Drupal picks up the new template. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Script missing after saving settings**: pages are served from Drupal's page cache. Clear caches with `drush cr` or **Configuration → Development → Performance → Clear all caches**. * **Visitors are tracked but you are not**: check the role and path visibility settings on the module page. Excluding the administrator role is a common setup. * **Logged-in users appear anonymous**: enable **Identify logged-in users** in the module settings; without it Drupal accounts are tracked like any other visitor. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Duda (/docs/guides/duda) Duda has a site-wide **Header HTML** panel in the site settings with two fields: **Head HTML**, printed inside `` on every page, and **Body End HTML**, printed before ``. The snippet goes into Head HTML, and the site must be republished for it to reach the live site. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Duda [#add-the-snippet-to-duda] 1. In the editor's side panel, click **More**, then **Settings**. 2. Click **Header HTML**. 3. Paste the snippet into **Head HTML** and click **Save**. 4. **Republish** the site. Saved header code shows in the editor and preview, but the live site is only updated on republish. Head HTML accepts ` ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Ecwid [#add-the-snippet-to-ecwid] 1. From your Ecwid admin, go to **Website** (or **Overview → Manage Instant Site**). 2. Scroll down to **SEO settings** and click **Add Code** under **Header meta tags and site verification**. 3. Paste the snippet and save. 4. Reload your Instant Site in the browser; there is no separate publish step for this setting. The **Custom JavaScript code** field under **Advanced website settings** on the same page also works, but it prints code at the end of ``, so prefer the header field. The store is rendered by Ecwid's embed script inside your own page, so add the snippet to the `` of the host site with that platform's method. See the [integration guides](/docs/guides) for WordPress, Wix, Squarespace, Webflow and others. One snippet covers the whole store: product pages, cart and checkout all render inside the same host page. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Track custom events [#track-custom-events] Ecwid's storefront JavaScript API fires `Ecwid.OnOrderPlaced` with the order right after a customer places it. Add this where the `Ecwid` object already exists: on Instant Site, under **Advanced website settings → Custom JavaScript code**; on an embedded store, after the Ecwid embed script on the host page. ```html ``` `vendorNumber` is the order number customers see in emails; `orderNumber` is Ecwid's internal ID. Event properties are limited to 2048 characters of JSON, so trim `items` to ID, quantity and price, or drop it, for stores with large orders. Ecwid also has a **Custom tracking code on Order Confirmation page** field under **Settings → General → Tracking & Analytics** (same plans as above). Code there runs on the `/checkout/order-confirmation` page and can use Ecwid's template variables when wrapped in `<#noescape>`: ```html ``` Use one method or the other, not both, or every order is counted twice. ## Troubleshooting [#troubleshooting] * **No Add Code button under SEO settings**: the store is on the Starter plan. Custom code needs Venture or higher. * **`Ecwid is not defined`**: the purchase snippet ran before Ecwid's script. Move it below the embed script, or keep it in the Instant Site **Custom JavaScript code** field, which runs at the end of the page. * **Embedded store, no pageviews**: the snippet belongs to the host site, not to Ecwid. Check the host page's source for `script.js?siteId=`. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Electron (/docs/guides/electron) An Electron renderer is a Chromium page, so Rybbit runs in it the same way it runs in a browser. For a renderer built with a bundler (Vite, webpack, the Electron Forge templates) install the `@rybbit/js` SDK and initialise it in the renderer entry file; for a plain HTML renderer put the snippet in the `` of the HTML file the window loads. In both cases allow `https://app.rybbit.io` in the renderer's Content Security Policy. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Electron [#add-the-snippet-to-electron] The SDK is bundled with your renderer code, so nothing is loaded from a CDN at runtime and `script-src 'self'` stays intact. 1. Install the package in your project: ```bash npm install @rybbit/js ``` 2. Initialise it once in the renderer entry file, before any other tracking call: ```js title="src/renderer.js" import rybbit from "@rybbit/js"; await rybbit.init({ analyticsHost: "https://app.rybbit.io/api", siteId: "YOUR_SITE_ID", }); ``` 3. Allow the analytics host in `connect-src`. Electron recommends a CSP for every renderer; when the renderer is loaded from a file, HTTP headers are not available, so use the meta tag: ```html title="src/index.html" ``` Paste the snippet into the `` of the renderer HTML and allow the host in both `script-src` (the script itself) and `connect-src` (its requests): ```html title="src/index.html" ``` If you set the CSP from the main process with `session.defaultSession.webRequest.onHeadersReceived` instead, add the same two sources there. Both methods track in-app navigation without extra code: the tracker wraps `history.pushState` and `history.replaceState` and listens for `popstate` and `hashchange`, so history-mode and hash-mode routers are covered. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Requests are blocked on the `file://` origin**: Rybbit's tracking endpoints answer CORS preflights for `http://` and `https://` origins only. A renderer opened with `win.loadFile()` runs on the `file://` origin and sends `Origin: null`, so Chromium blocks the `POST` to `/api/track` before it leaves the app. The same applies to custom schemes registered with `protocol.handle()`. Load the renderer over http(s) with `win.loadURL()` (your dev server in development, a hosted or locally served build in production) and tracking works. * **Console shows a CSP violation**: the message names the directive that blocked the request. Add `https://app.rybbit.io` to that directive; `connect-src` covers the SDK, and the script tag also needs `script-src`. * **Hostname is empty in the dashboard**: pageviews report the renderer's `window.location.hostname`, which is empty for `file://` pages. Filter by site rather than hostname. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Eleventy (/docs/guides/eleventy) Eleventy renders every page through a layout in `_includes/`, so the snippet goes in your base layout's ``. Keep the site ID in a global data file under `_data/` so the layout stays free of hard-coded values. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Eleventy [#add-the-snippet-to-eleventy] 1. Create `_data/rybbit.json` (create the `_data/` directory if it does not exist). Every template can then read it as `rybbit`. Change `instanceUrl` only if you self-host: ```json title="_data/rybbit.json" { "siteId": "YOUR_SITE_ID", "instanceUrl": "https://app.rybbit.io" } ``` 2. Open your base layout and add the tag inside ``. The `eleventy.env.runMode` check keeps the script out of `--serve` and `--watch` runs, so local development is not tracked; the `rybbit.siteId` check keeps it out of builds with no ID configured. ```html title="_includes/base.njk" ... {% if eleventy.env.runMode == "build" and rybbit.siteId %} {% endif %} ``` ```html title="_includes/base.liquid" ... {% if eleventy.env.runMode == "build" and rybbit.siteId %} {% endif %} ``` 3. Build and deploy the generated `_site/` directory: ```bash npx @11ty/eleventy ``` ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Snippet missing from the page source**: `npx @11ty/eleventy --serve` sets `runMode` to `serve`, so the guard removes the tag. Check the output of a plain build instead. * **Eleventy 1.x**: `eleventy.env.runMode` was added in Eleventy 2.0. On older versions drop the `runMode` condition and keep only the `rybbit.siteId` check. * **Custom data directory**: if your config sets `dir.data`, put `rybbit.json` in that directory instead of `_data/`. * **Several layouts**: a page only gets the tag if its layout chain ends in the layout you edited. Pages with `layout: none` or a different base layout need the tag added there too. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Ember (/docs/guides/ember) An Ember app has one HTML shell that every route renders into. Apps created with the current Vite-based `ember new` blueprint keep it at `index.html` in the project root; classic ember-cli apps keep it at `app/index.html`. Paste the snippet into the `` of that file. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Ember [#add-the-snippet-to-ember] Open `index.html` (or `app/index.html` in a classic ember-cli app) and paste the snippet inside ``, after the `{{content-for "head"}}` placeholder: ```handlebars title="index.html" MyApp {{content-for "head"}} {{content-for "head-footer"}} {{content-for "body"}} ... {{content-for "body-footer"}} ``` Keep the `{{content-for}}` placeholders in place; the build fills them in. Route transitions made by Ember's router (the default `history` location type) are tracked automatically as pageviews. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Track custom events [#track-custom-events] Call `window.rybbit.event()` from a component action. Guard the call so a blocked or still-loading script does not throw. ```js title="app/components/signup-button.gjs" import Component from "@glimmer/component"; import { on } from "@ember/modifier"; export default class SignupButton extends Component { trackSignup = () => { if (window.rybbit) { window.rybbit.event("signup_click", { location: "hero" }); } }; } ``` ## Troubleshooting [#troubleshooting] * **`index.html` is also used by the test runner**: `tests/index.html` is a separate file. Do not add the snippet there, or test runs send pageviews to your dashboard. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # FastAPI (/docs/guides/fastapi) FastAPI serves HTML through `Jinja2Templates`, so the snippet goes into the `` of the base template your pages extend. Keep the site ID in a `pydantic-settings` class and register it as a Jinja global so every template can render the tag without passing it through each route. The tracking script runs in a browser, so it only applies to routes that return template-rendered HTML. JSON endpoints have nothing for it to track. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to FastAPI [#add-the-snippet-to-fastapi] #### Configure the site ID [#configure-the-site-id] Install `pydantic-settings` and `jinja2` if you have not already: ```bash pip install pydantic-settings jinja2 ``` Define the settings, reading from the environment and an optional `.env` file: ```python filename="config.py" from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env") rybbit_instance_url: str = "https://app.rybbit.io" rybbit_site_id: str = "" settings = Settings() ``` ```bash filename=".env" RYBBIT_INSTANCE_URL=https://app.rybbit.io RYBBIT_SITE_ID=YOUR_SITE_ID ``` #### Register the values with Jinja [#register-the-values-with-jinja] `Jinja2Templates` exposes its environment as `templates.env`, so add the settings as globals once, next to where you create the templates object: ```python filename="main.py" from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from config import settings app = FastAPI() templates = Jinja2Templates(directory="templates") templates.env.globals["rybbit_instance_url"] = settings.rybbit_instance_url templates.env.globals["rybbit_site_id"] = settings.rybbit_site_id @app.get("/", response_class=HTMLResponse) async def index(request: Request): return templates.TemplateResponse(request=request, name="index.html") ``` #### Add the tag to your base template [#add-the-tag-to-your-base-template] Open the template your pages extend, usually `templates/base.html`, and add the tag inside ``: ```jinja filename="templates/base.html" {% block title %}My App{% endblock %} {% if rybbit_site_id %} {% endif %} ``` The `{% if %}` guard skips the tag when `RYBBIT_SITE_ID` is empty, so local development is not tracked unless you set the variable. Templates that do not extend the base need the same tag in their own ``. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Tag missing**: `Settings()` reads the environment when the module is imported, so restart Uvicorn (or your process manager) after changing `RYBBIT_SITE_ID`. * **Several `Jinja2Templates` instances**: globals are per environment. If routers create their own `Jinja2Templates`, set the globals on each, or share one instance from a module. * **Content Security Policy**: if middleware sets one, add your Rybbit host to `script-src` and `connect-src`, or [proxy the script](/docs/proxy-guide) through your own domain. * **Self-hosted instance**: set `RYBBIT_INSTANCE_URL` to your own domain without a trailing slash; the template appends the script path to it. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Flask (/docs/guides/flask) Flask renders Jinja templates, and most apps extend a shared base layout, so the snippet goes into that layout's ``. Flask exposes `app.config` to every template as `config`, so store the site ID there and reference it from the template. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Flask [#add-the-snippet-to-flask] #### Configure the site ID [#configure-the-site-id] Flask loads any environment variable prefixed with `FLASK_` into `app.config` when you call `from_prefixed_env()`. Set the values in your environment (`flask run` also reads a `.env` file when python-dotenv is installed): ```bash filename=".env" FLASK_RYBBIT_INSTANCE_URL=https://app.rybbit.io FLASK_RYBBIT_SITE_ID=YOUR_SITE_ID ``` Then load them where you create the app: ```python filename="app.py" from flask import Flask, render_template app = Flask(__name__) app.config.setdefault("RYBBIT_INSTANCE_URL", "https://app.rybbit.io") app.config.from_prefixed_env() @app.route("/") def index(): return render_template("index.html") ``` `from_prefixed_env()` parses values as JSON where it can, so a numeric site ID arrives as an `int`. It renders the same in the template. If you keep configuration in a Python file or class instead, add `RYBBIT_INSTANCE_URL` and `RYBBIT_SITE_ID` there and load it with `app.config.from_object()`. #### Add the tag to your base layout [#add-the-tag-to-your-base-layout] Open the template that your pages extend, usually `templates/base.html` (or `templates/layout.html`), and add the tag inside ``: ```jinja filename="templates/base.html" {% block title %}My App{% endblock %} {% if config.RYBBIT_SITE_ID %} {% endif %} ``` `config` is part of Flask's standard template context, so no context processor is needed. The `{% if %}` guard skips the tag when `RYBBIT_SITE_ID` is unset, so local development is not tracked unless you set the variable. Templates that do not extend the base layout need the same tag in their own ``. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Tag missing**: `from_prefixed_env()` reads the environment when the app is created, so restart the server after changing `FLASK_RYBBIT_SITE_ID`. Check `app.config["RYBBIT_SITE_ID"]` in `flask shell`. * **Macros imported into other templates**: `config` is added to the render context, not to imported templates. A macro that outputs the tag must be imported `with context`. * **Content Security Policy**: if you set one with Flask-Talisman or similar, add your Rybbit host to `script-src` and `connect-src`, or [proxy the script](/docs/proxy-guide) through your own domain. * **Self-hosted instance**: set `FLASK_RYBBIT_INSTANCE_URL` to your own domain without a trailing slash; the template appends the script path to it. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Flutter web (/docs/guides/flutter-web) A Flutter web app is served from the `web/index.html` template in your project, which `flutter build web` copies to `build/web/index.html`. The snippet goes in that file's ``. Flutter draws the UI into a canvas rather than DOM elements, so pageviews and manual events work as usual while anything that inspects the DOM does not. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Flutter web [#add-the-snippet-to-flutter-web] 1. Open `web/index.html` in your Flutter project. 2. Paste the snippet inside ``, above the `flutter_bootstrap.js` script that Flutter generates: ```html title="web/index.html" My App ``` 3. Rebuild with `flutter build web` and deploy the `build/web` folder. Navigation is tracked automatically with either URL strategy. Flutter's default hash strategy writes routes as `/#/settings`, and the tracker listens for `hashchange` and records `#/settings` as the path `/settings`. With `usePathUrlStrategy()` the Navigator uses the History API, and the tracker wraps `history.pushState` and `history.replaceState` and listens for `popstate`. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Track custom events [#track-custom-events] Flutter widgets are not HTML elements, so `data-rybbit-event` attributes have nothing to attach to. Call `window.rybbit.event()` from Dart through `dart:js_interop` instead: ```dart title="lib/analytics.dart" import 'dart:js_interop'; @JS('rybbit.event') external void _rybbitEvent(String name, [JSAny? properties]); void trackEvent(String name, [Map? properties]) { _rybbitEvent(name, properties?.jsify()); } ``` ```dart ElevatedButton( onPressed: () { trackEvent('signup_click', {'plan': 'pro'}); }, child: const Text('Sign up'), ) ``` Guard the call with `kIsWeb` if the same code also compiles for mobile or desktop targets. ## Troubleshooting [#troubleshooting] * **Clicks, outbound links and session replay show nothing**: the CanvasKit and Skwasm renderers paint into a single ``, so DOM-based features (`data-rybbit-event` attributes, outbound-link tracking, button-click autocapture, session replay) have no elements to observe. Use the Dart interop above for events. * **Deep links 404 after switching to `usePathUrlStrategy()`**: path URLs need the web server to rewrite every route to `index.html`; the Flutter dev server does this for you, production hosting must be configured. * **`rybbit` is undefined in Dart**: the snippet is `defer`red, so it is not ready during `main()`. Send events from user interactions, not from app start-up. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Framer (/docs/guides/framer) Framer has a **Custom Code** section in the site settings that injects HTML into the `` of every published page. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Framer [#add-the-snippet-to-framer] 1. Open your Framer project and go to **Site Settings → General**. 2. Scroll to **Custom Code**. 3. Paste the snippet into the **Start of `` tag** field (End of `` works too). 4. Click **Save**, then **Publish**. Custom code only reaches the live site on publish. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Track custom events [#track-custom-events] Use a **Code Override** to send events from any element in the canvas. 1. Select the element, open **Overrides** in the properties panel, and click **+ File** to create an override file such as `RybbitOverrides.tsx`. 2. Add an override that wraps the element's click handler: ```tsx // RybbitOverrides.tsx import type { ComponentType } from "react"; export function withRybbitClick(Component: ComponentType): ComponentType { return (props) => { const handleClick = () => { window.rybbit?.event("cta_click", { label: props.text ?? "cta" }); props.onClick?.(); }; return ; }; } ``` 3. Back in the properties panel, pick the file and apply `withRybbitClick` to the element. The same `window.rybbit.event()` call works inside a **Code Component**. ## Troubleshooting [#troubleshooting] * **Navigation between pages is not tracked**: Framer sites navigate client-side and the script detects those URL changes. If a view changes without the URL changing, call `window.rybbit.pageview()` from an override when it appears. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Fumadocs (/docs/guides/fumadocs) Fumadocs is a documentation framework for Next.js, so the snippet goes in the root layout, `app/layout.tsx`, rendered with `next/script` so it is on every page including the docs routes. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Fumadocs [#add-the-snippet-to-fumadocs] Open `app/layout.tsx` and render the `Script` component inside ``, next to the `RootProvider` that Fumadocs already puts there: ```tsx title="app/layout.tsx" import { RootProvider } from "fumadocs-ui/provider/next"; import Script from "next/script"; import type { ReactNode } from "react"; export default function Layout({ children }: { children: ReactNode }) { return ( {children} ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Ghost [#add-the-snippet-to-ghost] 1. Open **Ghost Admin** and click the **Settings** gear in the bottom left. 2. Under **Advanced**, open **Code injection**. 3. Paste the snippet into **Site header** and click **Save**. If you maintain your own theme instead, add the snippet to `default.hbs` just before ``. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Ghost(Pro) with a custom theme**: code injection still applies on top of the theme, so prefer it over editing `default.hbs`. * **Member-only content**: pages behind the paywall are tracked like any other page for visitors who can view them. * **Portal and newsletter previews** render in an iframe and are not tracked. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # GitBook (/docs/guides/gitbook) GitBook does not let you add custom scripts to a published site, and its integration platform does not let third parties inject them either. This page explains what that means and what your options are. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to GitBook [#add-the-snippet-to-gitbook] There is currently no supported way to load the Rybbit snippet on a GitBook-hosted site. The old **Custom scripts → Head** setting was removed, and GitBook's [integration scopes](https://gitbook.com/docs/developers/integrations/configurations) reserve `site:script:inject` for GitBook's own integrations: "Building integrations that inject JavaScript into a site or space is not possible at this time." Your options: * **Use GitBook's built-in analytics.** The **Insights** section in the GitBook app reports page views and search terms for published sites without any script. * **Wait for a native integration.** A Rybbit integration would need GitBook to build or approve it. Ask GitBook support for it, and watch the [Rybbit roadmap](/docs/roadmap). * **Host the docs elsewhere.** [Docusaurus](/docs/guides/docusaurus), [Mintlify](/docs/guides/mintlify), [VitePress](/docs/guides/vitepress), [Starlight](/docs/guides/starlight) and [Fumadocs](/docs/guides/fumadocs) all take the snippet directly, and GitBook can export a space as Markdown to migrate. The rest of your site (marketing pages, app, blog) is unaffected: add the snippet there as usual so traffic from the docs is still attributed by referrer. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Google Tag Manager (/docs/guides/google-tag-manager) Google Tag Manager loads Rybbit through a **Custom HTML** tag fired on **All Pages**, so nothing in your site's code changes. Events you already push to the `dataLayer` can be forwarded to Rybbit with a second tag. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Google Tag Manager [#add-the-snippet-to-google-tag-manager] 1. Open your GTM container and go to **Tags → New**. 2. Under **Tag Configuration**, choose **Custom HTML** and paste the snippet: ```html ``` 3. Under **Triggering**, choose **All Pages** (the built-in Page View trigger). 4. Name the tag, for example `Rybbit - Loader`, and click **Save**. 5. Click **Submit** and publish the container version. GTM rebuilds Custom HTML tags before injecting them and can drop non-standard attributes. Keep the site ID in the URL (`?siteId=`) as shown; the older `data-*` attribute form is not reliable here. Use **Preview** mode to confirm the tag fires on page load before publishing. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Track custom events [#track-custom-events] Once the loader tag is published, `window.rybbit` is available to your own JavaScript and to other GTM tags. #### Forward dataLayer events [#forward-datalayer-events] If your site already pushes events like `dataLayer.push({ event: "signup" })`, one tag can forward them to Rybbit: 1. Create a **Custom Event** trigger. Set the event name to a regex such as `signup|purchase|add_to_cart` (tick **Use regex matching**). 2. Create a **Custom HTML** tag with that trigger: ```html ``` `{{Event}}`, `{{Page Path}}` and `{{Referrer}}` are GTM built-in variables. Add a **Data Layer Variable** for any custom field you push (for example `plan`) and reference it the same way. #### Use GTM's click and form triggers [#use-gtms-click-and-form-triggers] The same pattern works with built-in **Click** and **Form Submission** triggers: ```html ``` Enable the **Click Text** and **Click URL** built-in variables under **Variables → Configure**. ## Troubleshooting [#troubleshooting] * **Event tag fires before Rybbit loads**: the loader script is deferred, so an event tag on the same page load may run first and hit the `if (window.rybbit)` guard. Open the event tag's **Advanced Settings → Tag Sequencing** and set the loader tag to fire before it, or trigger the event tag on **Window Loaded** instead of **Page View**. * **Consent mode**: if your container gates tags on consent, add the loader tag to the same consent group as your other analytics tags, otherwise it never fires. * **Tag shows in Preview but not live**: the container version was not published. Click **Submit** and publish. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Hexo (/docs/guides/hexo) Hexo 5 and later ship an injector API that adds HTML to the `` of every generated page from a small file in your site's `scripts/` directory, so no theme edits are needed. Editing the theme's head partial is the fallback for older versions. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Hexo [#add-the-snippet-to-hexo] 1. Add your site ID to the site `_config.yml` at the root of your project (not the theme config). `instance_url` is only needed if you self-host: ```yaml title="_config.yml" rybbit: site_id: "YOUR_SITE_ID" instance_url: "https://app.rybbit.io" ``` 2. Create `scripts/rybbit.js` (create the `scripts/` directory if it does not exist). Hexo loads every file in this directory at startup with the `hexo` object in scope: ```javascript title="scripts/rybbit.js" const { site_id: siteId, instance_url: instanceUrl = "https://app.rybbit.io" } = hexo.config.rybbit || {}; if (siteId) { hexo.extend.injector.register( "head_end", `` ); } ``` `head_end` places the tag right before ``. With no third argument the injector applies to every page type. Open your theme's head partial and paste the snippet before ``. For the default Landscape theme that file is `themes/landscape/layout/_partial/head.ejs`; other themes keep an equivalent partial under `layout/`. ```html title="themes/landscape/layout/_partial/head.ejs" ... ``` Theme updates overwrite this file, so prefer the injector when your Hexo version supports it. Regenerate and deploy the `public/` directory: ```bash hexo clean && hexo generate ``` ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **`hexo.extend.injector` is undefined**: the injector API arrived in Hexo 5.0. Check `hexo version` and upgrade, or use the theme layout method. * **Tag missing after adding the script**: run `hexo clean` before `hexo generate`; Hexo caches rendered output in `db.json` and can serve stale pages. * **Local previews are tracked**: `hexo server` runs the same injector as `hexo generate`. Leave `site_id` empty in a local copy of `_config.yml`, or accept the local pageviews. * **Theme config vs site config**: `hexo.config` reads the root `_config.yml`, not `themes//_config.yml`. Put the `rybbit` block in the root file. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Hostinger Website Builder (/docs/guides/hostinger) Hostinger Website Builder has a **Custom code** integration in the website settings that adds code to every page. Script tags are placed at the end of `` by default; a `data-render-head` attribute moves them into ``. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Hostinger Website Builder [#add-the-snippet-to-hostinger-website-builder] 1. Open your site in the builder and click **⋯** (More) at the top to open the website settings, then choose **Integrations**. 2. Select **Custom code**. 3. Paste the snippet with `data-render-head` added so it loads from ``: ```html ``` 4. Save the changes, then click **Update website** to publish. Custom code runs on the live site and in preview, not inside the editor. Without `data-render-head` the builder moves ` ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to htmx [#add-the-snippet-to-htmx] Open the template that renders the full HTML document (the one that already loads htmx) and paste the snippet inside ``: ```html title="layout.html" My htmx App
...
``` Only full-page responses need the tag. Partial responses that htmx swaps into the page must not include it, or the script is re-evaluated on every swap. Navigations that htmx records in browser history are tracked automatically as pageviews: `hx-push-url` and `hx-boost` call `history.pushState()`, `hx-replace-url` calls `history.replaceState()`, and the back and forward buttons fire `popstate`. All three are hooked by the tracker. A swap without `hx-push-url` leaves the URL unchanged and is not counted as a pageview; send a [custom event](/docs/track-events) for those interactions instead.
### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes.
## Track custom events [#track-custom-events] Use the `data-rybbit-event` attribute on the element that triggers the request. The tracker listens for clicks on `document`, so it also works on content that htmx swapped in after the page loaded: ```html ``` To record an event when the server has actually processed the request, listen for htmx's `htmx:afterRequest` event and call `window.rybbit.event()` from there. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # HubSpot (/docs/guides/hubspot) HubSpot Content Hub (formerly CMS Hub) has a **Site header HTML** setting that injects code into the `` of every website page, landing page, blog post and knowledge base article on a domain, so no template edits are needed. Individual pages can add or override code from the content editor. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to HubSpot [#add-the-snippet-to-hubspot] This adds the snippet to all HubSpot-hosted content on a domain. 1. In your HubSpot account, click the **settings** icon in the top navigation bar. 2. In the left sidebar, go to **Content > Pages**. 3. Use the **Choose a domain to edit its settings** dropdown to pick the domain, or stay on the default settings to apply the code to every domain. 4. Paste the snippet into the **Site header HTML** field. 5. Click **Save**. If a domain has **Override default settings** turned on, the default header code no longer applies to it. Add the snippet to that domain's own settings as well. Use this to track a single page, or when someone else controls the domain settings. 1. Open **Content > Website Pages** (or **Landing Pages**, **Blog**, **Knowledge Base**) and click the page name to open the editor. 2. Click the **Settings** menu, then select **Advanced**. 3. Under **Additional code snippets**, paste the snippet into the **Head HTML** field. 4. Click **Update** or **Publish**. Per-page head HTML is added on top of the site header HTML, so a page tracked by both methods loads the script twice. Pick one. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Track custom events [#track-custom-events] HubSpot forms submit inside the page without a full reload, so track them with a custom event. Add `data-rybbit-event` to a call-to-action button, or listen for HubSpot's form-submitted message in a small script placed after the snippet: ```html ``` See [Track custom events](/docs/track-events) for the event API. ## Troubleshooting [#troubleshooting] * **Snippet missing on one domain**: the site header HTML is per domain. Check whether that domain has **Override default settings** enabled and add the snippet there. * **Duplicate pageviews**: the same page has the snippet in both site header HTML and its own **Head HTML**. Remove one. * **Editor and preview**: the content editor renders pages in an iframe with unpublished changes. Previews are tracked like any other visit if they load the script, so avoid checking analytics from the editor. * **Quotes**: header HTML set at the domain level also loads on HubSpot quotes hosted on that domain. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Hugo (/docs/guides/hugo) Hugo builds every page from templates under `layouts/`, so the cleanest place for the snippet is a partial template that your base template includes on every page. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Hugo [#add-the-snippet-to-hugo] 1. Add your site ID under `params` in your Hugo config (`hugo.toml`, or `config.toml` in older Hugo versions). `rybbitInstanceURL` is only needed if you self-host: ```toml title="hugo.toml" [params] rybbitSiteID = "YOUR_SITE_ID" rybbitInstanceURL = "https://app.rybbit.io" ``` 2. Create `layouts/partials/rybbit-analytics.html` (create the `layouts/partials/` directory if it does not exist): ```html title="layouts/partials/rybbit-analytics.html" {{ if and (not hugo.IsServer) .Site.Params.rybbitSiteID }} {{ end }} ``` `not hugo.IsServer` skips the script during `hugo server`, so local development is not tracked. The `rybbitSiteID` check keeps the tag out of builds that have no ID configured. 3. Include the partial from your base template, typically `layouts/_default/baseof.html`, inside ``. If your theme owns `baseof.html`, copy it into your project's `layouts/_default/` first so the theme can be updated without losing the change; a theme footer partial such as `layouts/partials/footer.html` also works. ```html title="layouts/_default/baseof.html" ... {{ partial "rybbit-analytics.html" . }} ``` 4. Build with `hugo` and deploy the generated `public/` directory. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Snippet missing from the page source**: `hugo server` never emits it because of the `hugo.IsServer` guard. Check the output of a production `hugo` build instead. * **Theme overrides**: a file at `layouts/_default/baseof.html` in your project takes precedence over the theme's copy, so make sure you edited the one Hugo actually renders. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Integration guides (/docs/guides) Rybbit works on any site that can load a script tag. Each guide below shows exactly where the tracking snippet goes on that platform and how to confirm it is working. Don't see your platform? Paste the snippet from **Site Settings → Tracking Script** into the `` of every page, or follow the [tracking script](/docs/script) reference. # Instapage (/docs/guides/instapage) Instapage takes custom scripts at two levels: **Workspace scripts** (under **Assets > Scripts**) apply to every page in a workspace, and each page also has its own **JavaScript** and **HTML/CSS** settings with a **Head** section. Custom HTML, CSS and JavaScript are included on all Instapage plans; Workspace scripts are limited to some subscriptions and show an upgrade prompt otherwise. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Instapage [#add-the-snippet-to-instapage] 1. In the left menu, open **Assets**, then **Scripts**. 2. Paste the snippet into the **Head** section. 3. Above the editor, choose how it applies: option **A** adds it to every past and future page in the workspace; **B** leaves it off until you switch it on per page under **Scripts & Privacy**; **B + C** turns it on for all pages but lets you disable it per page. 4. Click **Apply**. Workspace scripts load on the live URL before any page-level script, so the snippet is in place before other code on the page runs. 1. Open the page in the builder and click **Settings** in the right sidebar. 2. Open the **HTML/CSS** tab and paste the snippet into **Head**. (The **JavaScript** tab has the same three sections and wraps your code in ` ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Jekyll [#add-the-snippet-to-jekyll] 1. Add your site ID to `_config.yml`. `rybbit_instance_url` is only needed if you self-host: ```yaml title="_config.yml" rybbit_site_id: "YOUR_SITE_ID" rybbit_instance_url: "https://app.rybbit.io" ``` 2. Create `_includes/rybbit-analytics.html` (create the `_includes/` directory if it does not exist): ```html title="_includes/rybbit-analytics.html" {% if jekyll.environment == "production" and site.rybbit_site_id %} {% endif %} ``` The `jekyll.environment` check keeps the script out of local `jekyll serve` builds; the `site.rybbit_site_id` check keeps it out of builds with no ID configured. 3. Include it from your layout's ``: ```html title="_layouts/default.html" ... {% include rybbit-analytics.html %} ``` Open `_layouts/default.html` and paste the snippet inside ``, wrapped in a production check so local `jekyll serve` builds are not tracked: ```html title="_layouts/default.html" ... {% if jekyll.environment == "production" %} {% endif %} ``` Build for production and deploy the generated `_site/` directory: ```bash JEKYLL_ENV=production jekyll build ``` GitHub Pages sets `jekyll.environment` to `production` during its own build, so no extra configuration is needed there. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Snippet missing from the page source**: `jekyll serve` and a plain `jekyll build` run with `jekyll.environment` set to `development`, so the production guard removes the tag. Build with `JEKYLL_ENV=production` and check that output. * **Config changes not applied**: Jekyll reads `_config.yml` once at startup, so restart `jekyll serve` after adding `rybbit_site_id`. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Joomla (/docs/guides/joomla) Joomla has no built-in head-code setting, so the snippet goes in either a **Custom** module assigned to every page or in your template's `index.php`. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Joomla [#add-the-snippet-to-joomla] No file edits are needed, and the module survives template updates. 1. In the Administrator panel go to **Content → Site Modules** (**Extensions → Modules** on Joomla 3), click **New** and choose **Custom**. 2. Set **Title** to `Rybbit`, **Show Title** to **Hide**, and **Position** to any position your template renders on every page. Templates rarely expose a `` position; the loader below moves the script into the head from wherever the module is rendered. 3. In the editor, use the **Toggle Editor** button (or **Code** view) so the script is not stripped, then paste: ```html ``` 4. Set **Status** to **Published**, choose **On all pages** under **Menu Assignment**, and click **Save & Close**. Edit a child template (Joomla 4.1+) or your own copy of the template, otherwise the change is lost on the next template update. 1. Go to **System → Site Templates** and open your active template. 2. Open `index.php` and add the snippet just before ``: ```php ``` 3. Click **Save & Close**. ### Verify installation [#verify-installation] Open your live site in a new tab and click through a few pages. Within a few seconds the pageviews appear in the Rybbit dashboard. If nothing shows up: * View the page source and search for `script.js?siteId=` to confirm the snippet is on the page. * Open the browser Network tab and check that `script.js` returns `200` and that `POST` requests go to `/api/track`. * Disable ad blockers, or set up a [proxy](/docs/proxy-guide) so the script loads from your own domain. * See the [script troubleshooting guide](/docs/script-troubleshooting) for other common causes. ## Troubleshooting [#troubleshooting] * **Module not on the page**: confirm it is published, assigned to **On all pages**, and that the chosen position exists in your template. Then clear the cache under **System → Clear Cache**. * **TinyMCE strips the script**: open the **Editor - TinyMCE** plugin under **System → Plugins** (**Extensions → Plugins** on Joomla 3) and add `script[src|defer]` to **Extended Valid Elements**, or keep using the toggled-off editor when saving the module. * **Page caching**: the **System - Page Cache** plugin serves cached HTML, so clear the cache after adding the snippet. The script itself loads client-side and works with cached pages. ## Next steps [#next-steps] * [Track custom events](/docs/track-events) such as signups, purchases and button clicks. * [Identify users](/docs/identify-users) to connect sessions to accounts. * [Proxy the script](/docs/proxy-guide) through your own domain to bypass ad blockers. * [Script attributes](/docs/script#script-attributes) let you skip or mask URLs and tag events. # Kajabi (/docs/guides/kajabi) Kajabi has a site-wide **Header Page Scripts** field under **Site details** that puts code in the `` of every page, but it deliberately skips Offer checkout and upsell pages. Those have their own **Checkout Tracking Code** setting, so a full install is two pastes. ### Get your tracking snippet [#get-your-tracking-snippet] In your Rybbit dashboard, open **Site Settings → Tracking Script** and copy your snippet. It looks like this: ```html ``` `YOUR_SITE_ID` is the numeric ID of your site. If you self-host Rybbit, `app.rybbit.io` is the domain of your own instance. ### Add the snippet to Kajabi [#add-the-snippet-to-kajabi] **Site-wide pages** 1. From the **Dashboard**, open **Settings**. 2. Under **Site settings**, click **Site details**. 3. Scroll to **Page scripts** and paste the snippet into **Header Page Scripts**. 4. Save. **Checkout and upsell pages** 1. From the **Dashboard**, open **Settings**. 2. Under **Payment settings**, click **Checkout**. 3. In the **Checkout Tracking Code** section, click to edit the **Header tracking code** and paste the same snippet. 4. Save. Do not paste the snippet into a page's **Custom Code** section (under **Customize > Settings**). That field is for CSS and JavaScript without `