# Welcome to Ripples.sh Lightweight analytics for indie products. One script tag. No complex setup. Free until $1K MRR. ## What is Ripples.sh? Ripples.sh gives you a pre-built analytics dashboard with traffic sources, signups, payments, and payback — all from a single script tag. No cookies banner needed for basic analytics, no account needed to start. Out of the box you get: - **Pageviews & sessions** — automatic tracking with SPA support - **Traffic sources** — referrers, UTM params, ad click IDs (Google, Meta, TikTok, etc.) - **Device & browser info** — OS, browser, screen size, language - **Web Vitals** — LCP, FCP, CLS, INP, TTFB with an experience score - **Bot filtering** — known crawlers are automatically excluded - **Visitor profiles** — anonymous and identified user tracking ## Quick start Add this script tag to your site, right before the closing `` tag: ```html ``` That's it. Pageviews, sessions, and Web Vitals will be tracked automatically. See [Installation](/docs/installation) for framework-specific guides. ## How it works The script (~3KB gzipped) runs in the browser and sends events to `https://api.ripples.sh/collect` using `sendBeacon`. Each event includes the page URL, referrer, UTM parameters, device info, and a randomly generated visitor ID stored in `localStorage`. Sessions are tracked per-tab with a 30-minute inactivity timeout. No server-side code is needed. ## SDK methods overview Beyond automatic pageview tracking, the JS SDK exposes these methods: | Method | Description | | --- | --- | | `[ripples.track()](/docs/custom-events)` | Track significant product usage only — not a generic event log. Powers the Activation dashboard | | `[ripples.identify()](/docs/identify)` | Associate a visitor with an authenticated user | | `[ripples.pageview()](/docs/spa)` | Manually trigger a pageview (for edge cases) | | `[ripples.getVisitorId()](/docs/identify#get-visitor-id)` | Get the visitor's unique ID for server-to-server attribution | --- # Installation Add Ripples.sh to your site in under a minute. Works with any framework or static site. ## Script tag The simplest way to install. Add this to your HTML ``: ```html ``` ## Script attributes | Attribute | Description | | --- | --- | | `data-token` (required) | Your project token. Found in your dashboard under the install snippet. | | `data-endpoint` (optional) | Override the default collection endpoint. Useful for self-hosted setups or proxying through your own domain. | | `data-platform` (optional) | Override the detected platform (e.g. `ios`, `android`). Used when loading the script inside a native webview. The script auto-detects Capacitor, React Native, Flutter, Electron, and Tauri — use this attribute only for custom containers. | | `data-sdk-name` (optional) | Override the detected SDK name (e.g. `capacitor`, `react-native`). Auto-detected for most popular frameworks — use this attribute only for custom integrations. | | `defer` (optional) | Recommended. Loads the script without blocking page render. | ## Next.js Add the script in your root layout: ```jsx import Script from 'next/script' export default function RootLayout({ children }) { return (
``` > SPA routing is handled automatically. The script listens to history.pushState and popstate events, so pageviews are tracked on every route change. No extra setup needed. ## Vue / Nuxt For Nuxt 3, add to your `nuxt.config.ts`: ```typescript export default defineNuxtConfig({ app: { head: { script: [ { src: 'https://cdn.ripples.sh/v.js', defer: true, 'data-token': 'YOUR_TOKEN', }, ], }, }, }) ``` ## Astro Add to your base layout component: ```html ``` ## Laravel / Blade Add to your main layout file: ```html ``` ## Native webview (Capacitor, React Native, etc.) When your web app runs inside a native webview, the script **auto-detects** the SDK and platform for the most popular frameworks: | Framework | SDK name | Platform | |-----------|----------|----------| | Capacitor | `capacitor` | `ios` or `android` (from Capacitor API) | | React Native WebView | `react-native` | — | | Flutter InAppWebView | `flutter` | — | | Electron | `electron` | — | | Tauri | `tauri` | — | If the platform can't be auto-detected (e.g. React Native only exposes the bridge, not the OS), set it explicitly: ```html ``` Or when injecting the script programmatically (e.g. in Capacitor): ```typescript const script = document.createElement('script'); script.defer = true; script.src = 'https://cdn.ripples.sh/v.js'; script.dataset.token = 'YOUR_TOKEN'; script.dataset.platform = Capacitor.getPlatform(); // 'ios' or 'android' script.dataset.sdkName = 'capacitor'; document.head.appendChild(script); ``` > Bot detection is adapted automatically. Browser-specific signals (plugins, Chrome API, Notification API) are skipped when a native container is detected, preventing false positives. ## Verify installation After adding the script, open your site and check the browser's Network tab. You should see a POST request to `api.ripples.sh/collect`. Your dashboard will start showing data within seconds. ## Loading before the script is ready If you need to call SDK methods (like `ripples.track()`) before the script has loaded, use the queue pattern: ```html ``` Any calls made before the SDK loads will be replayed automatically once it initializes. --- # Track Product Usage Use ripples.track() only for significant product usage — the actions that prove a user got value. Not a generic event log. ## ripples.track() `track()` is **only** for significant product usage — the actions that indicate a user got real value from your product (created a budget, sent a message, invited a teammate). It is **not** a generic event log like PostHog or Mixpanel. Every `track()` call is counted as meaningful engagement and feeds the Activation dashboard, so noise here directly pollutes your funnel. **Do not send:** pageviews, banner impressions, button clicks, modal opens, "viewed X" events, or anything you'd send "just in case." If you're not sure the action represents real product value, leave it out. ```javascript ripples.track("created a budget", { area: "budgets", }) ``` Ripples auto-detects activation moments (first occurrence per user per action), computes adoption rates by product area, and correlates usage patterns with retention and payment. ## Client-side or server-side? `track()` exists in every Ripples SDK and the rules are the same everywhere — only the call site changes. - **Client-side** (`ripples.track(...)` from the JS SDK, including in Telegram Mini Apps and the iOS SDK) — best for UI-driven actions where the user clicked something and the result is visible to them: created a budget, sent a message, exported a report. - **Server-side** (`$ripples->track(...)` in PHP, `ripples.track(...)` in Python) — best for actions that must not be lost: webhook-confirmed signups, background-job completions, anything the browser shouldn't be the source of truth for. Deliveries are batched and retried automatically. If the same action can be observed from either side, prefer the side closest to the source of truth. A "subscription started" event belongs server-side (driven by Stripe webhook). A "report exported" event can live client-side. Don't double-fire — one action = one event, regardless of where it was sent from. See [PHP SDK](/docs/php-sdk) and [Python SDK](/docs/python-sdk) for the full server-side reference. ## What NOT to track Ripples is opinionated on purpose — it's the anti-PostHog. Keep these out of `track()`: - **Pageviews** — already captured automatically by the script tag. Call `ripples.pageview()` only for SPA route changes. - **Banner / ad / component impressions** — "user saw X" is not product usage. - **Button clicks and modal opens** — unless the click *completes* a meaningful action, don't send it. - **"Viewed profile," "opened settings," "scrolled to bottom"** — navigation, not activation. - **Debug or diagnostic events** — use logs, not `track()`. The test: would a founder describe this action as "the user got value from my product"? If not, don't track it. ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `name` (required) | `string` | What the user did. Be specific: `"created a budget"`, not `"budgets"`. | | `options` (optional) | `object` | Options object with `area`, `activated`, and any custom properties. | ### Options | Key | Type | Description | | --- | --- | --- | | `area` (optional) | `string` | Product area this action belongs to (e.g. `"budgets"`, `"sharing"`). Groups actions in the dashboard. | | `activated` (optional) | `boolean` | Set to `true` on the specific occurrence when activation happens for this user. This does not mark the event type as an activation event — it marks this particular moment as when the user activated. For example, `"sent message"` is a regular event, but when a user sends their 10th message you may consider that their activation moment and send that occurrence with `activated: true`. | | `[custom]` (optional) | `string | number` | Any additional context. Values should be strings or numbers. | ## Examples ### Finance app ```javascript // User created a budget — group under "budgets" area ripples.track("created a budget", { area: "budgets" }) // User set a spending limit ripples.track("set budget limit", { area: "budgets", limit: "500" }) // User exported a report ripples.track("exported report", { area: "reports", format: "csv" }) ``` ### Personal finance tracker ```javascript // User added a transaction — everyday action ripples.track("added transaction", { area: "transactions" }) // User added their 10th transaction — we consider this their activation moment ripples.track("added transaction", { area: "transactions", activated: true, // only on THIS occurrence, not every "added transaction" }) // User exported a report ripples.track("exported report", { area: "reports", format: "csv" }) ``` ## Product areas Use the `area` option to group actions into zones of your product. Areas are auto-discovered from your `track()` calls — no setup required. They appear in the Activation dashboard as an adoption heatmap: ``` Product Areas Adoption → Paid ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Basic Tracking ████████████ 89% 6% Budgets ██████ 41% 18% Sharing ████ 27% 22% Reports ██ 14% 9% ``` ## Activation flag Use `activated: true` to mark the **specific moment** a user activates — not to label an event type as "the activation event." The same event name can be sent many times without the flag. You add `activated: true` only on the occurrence that represents the milestone. Your app decides when that is (e.g. 10th transaction added, first budget over $100). ```javascript // User added their 10th transaction — we consider this their activation ripples.track("added transaction", { area: "transactions", activated: true, // only on this occurrence }) // All other "added transaction" calls are normal — no activated flag ripples.track("added transaction", { area: "transactions" }) ``` ## Alternative: queue syntax You can also use the function-call style, which works even before the SDK has loaded: ```javascript ripples("track", "created a budget", { area: "budgets" }) ``` Both `ripples.track(...)` and `ripples("track", ...)` are equivalent. ## Best practices - **Only track significant usage.** Every `track()` call feeds the Activation dashboard — pageviews, clicks, and impressions pollute it. - **Be specific with action names** — `"created a budget"` is better than `"budgets"` or `"click"`. - **Use areas consistently** — pick area names that match how your product is organized. - **Use `activated: true` on the specific occurrence** when you consider the user activated — not on every call of that event type. - Events are sent via `sendBeacon` so they won't block navigation or slow down your site. --- # Identify Users Associate anonymous visitors with authenticated users using ripples.identify(). > Note: Identifying users stores the user ID in localStorage. Once identified, all subsequent events (including pageviews) will include the user ID until the storage is cleared. ## ripples.identify() Call this method after a user logs in or signs up: ```javascript ripples.identify({ id: "user_123", // required email: "jane@acme.com", // optional name: "Jane Doe", // optional avatar_url: "https://...", // optional signed_up_at: "2026-01-15T...", // optional — ISO 8601 date string plan: "pro", // optional }) ``` > Call on every authenticated page load — not just on login. It's safe to call multiple times and ensures the user stays linked across page navigations and sessions. ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `props` (required) | `object` | User properties object. Must include `id`. | ### Properties object | Key | Type | Description | | --- | --- | --- | | `id` (required) | `string` | Your internal user ID. This links anonymous sessions to the user. | | `email` (optional) | `string` | User's email address. Shown in the visitor profile. | | `name` (optional) | `string` | Display name. Shown in the visitor profile instead of the anonymous name. | | `avatar_url` (optional) | `string` | URL to the user's avatar image. Also accepts `avatar` as an alias. Shown in the visitor profile. | | `signed_up_at` (optional) | `string` | ISO 8601 date string of when the user signed up. Enables cohort-based retention and activation analysis. If omitted, Ripples sets it to the time of the first `identify()` call. | | `plan` (optional) | `string` | The user's current plan (e.g. `"free"`, `"pro"`). Stored on the visitor profile and used for plan-based segmentation. | | `[custom]` (optional) | `string | number` | Any additional properties you want to attach to the user (e.g. `company`, `role`). | ## Examples ### After login ```javascript // After successful authentication async function onLoginSuccess(user) { ripples.identify({ id: user.id, email: user.email, name: user.name, avatar_url: user.profile_avatar_url, signed_up_at: user.created_at, // ISO 8601 plan: user.plan, }) } ``` ### React example ```javascript function useIdentify(user) { useEffect(() => { if (user) { ripples.identify({ id: user.id, email: user.email, name: user.displayName, avatar_url: user.avatarUrl, signed_up_at: user.createdAt, }) } }, [user]) } ``` ## Alternative syntax You can also pass the user ID as a string with properties as a second argument: ```javascript // Object style (recommended) ripples.identify({ id: "user_123", name: "Jane" }) // Queue style ripples("identify", { id: "user_123", name: "Jane" }) // String ID style (legacy) ripples("identify", "user_123", { name: "Jane" }) ``` ## ripples.getVisitorId() Returns the current visitor's unique ID. Use this to link client-side activity with your backend — for example, to send server-to-server events. ```javascript const visitorId = ripples.getVisitorId() // "a1b2c3d4-e5f6-7890-abcd-ef1234567890" ``` A common pattern is to pass the visitor ID to your backend on signup or checkout, so you can attribute server-side events (like Stripe payments) back to the visitor: ```javascript // Send visitor ID with your API call await fetch('/api/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, plan: plan, ripples_visitor_id: ripples.getVisitorId(), }), }) ``` ```php // Store the visitor ID alongside the user record // Later, use it to send server-side events via the API $user->ripples_visitor_id = $request->ripples_visitor_id; ``` > Visitor ID persistence: The visitor ID is stored in a cross-subdomain cookie (_rpl_vid) with a 2-year expiry, and also in localStorage as a fallback. This means app.yoursite.com and www.yoursite.com share the same visitor ID. ## How it works 1. When you call `ripples.identify()`, the user ID is saved to `localStorage` under the key `_rpl_uid`, and the avatar URL under `_rpl_uav`. 2. An `identify` event is sent to the collection endpoint with the user properties. 3. All future events (pageviews, tracks) will include this user ID automatically. 4. The visitor profile in your dashboard will show the user's name, email, and avatar instead of the anonymous identifier. 5. The first `identify()` call also sets `signed_up_at` on the profile (if not already set or if you pass an earlier date), which powers cohort and activation analytics. > Cross-device tracking: Once a user is identified on one device, their activity will be linked across all devices where they log in. --- # SPA Support Single-page app routing is handled automatically. Here's how it works and when you might need manual control. ## Automatic tracking The SDK automatically tracks pageviews for single-page applications by listening to: - `history.pushState` — triggered by client-side navigation (React Router, Vue Router, Next.js, etc.) - `popstate` — triggered when the user clicks the browser's back/forward buttons No configuration is needed. Just install the script and every route change will be tracked. > Works with all major frameworks: React, Vue, Svelte, Angular, Next.js, Nuxt, SvelteKit, Astro (with client-side routing enabled). ## Manual pageview In rare cases where you need to trigger a pageview manually (e.g. after a hash change or a custom router), use: ```javascript ripples.pageview() ``` This sends a pageview event with the current `location.href`, `location.pathname`, and `document.title`. ## Session & entry page tracking Sessions are tracked per-tab using `sessionStorage`. A session expires after **30 minutes of inactivity**. Each session records: - **Entry URL** — the first page the user landed on in this session - **Entry path** — the path portion of the entry URL - **Entry referrer** — the domain that brought the user to your site These values persist across all pageviews within the same session, so you can always see where a session originally started — even after the user has navigated away from the landing page. ## Web Vitals The SDK automatically collects Core Web Vitals using the browser's `PerformanceObserver` API: | Metric | Description | | --- | --- | | `LCP` | Largest Contentful Paint — measures loading performance | | `FCP` | First Contentful Paint — time to first visible content | | `CLS` | Cumulative Layout Shift — measures visual stability | | `INP` | Interaction to Next Paint — measures responsiveness | | `TTFB` | Time to First Byte — measures server response time | These are combined into an **Experience Score** (0–100) shown in your dashboard. Vitals are reported once per page load, after at least 3 metrics have been collected (or after a 10-second timeout). --- # Stripe Integration Automatically track revenue and attribute it to the visitors who converted. ## Overview The Stripe integration connects your Stripe account to Ripples so you can: - See revenue attributed to each visitor and their acquisition source - Track charges, subscriptions, and invoices automatically - Backfill historical payment data when you first connect - Handle refunds (negative revenue events are created automatically) Revenue events appear in the visitor timeline and contribute to each profile's lifetime revenue total. ## Prerequisites Before connecting Stripe, make sure you have: 1. **Persist mode enabled** — Ripples uses a cookie (`_rpl_vid`) to track visitors across sessions. This is the default behavior. 2. **User identification** — Call `ripples.identify()` when users sign in so we can match Stripe charges to visitor profiles by email. ```javascript // Call this after login / signup ripples.identify({ id: "user_123", email: "jane@example.com", name: "Jane Doe", plan: "pro", }) ``` ## Create a Restricted API Key We recommend using a **restricted API key** with minimal permissions rather than your secret key. 1. Go to [Stripe Dashboard → API Keys](https://dashboard.stripe.com/apikeys) 2. Click **"Create restricted key"** 3. Give it a name like `Ripples Analytics` 4. Set the following permissions to **Read**: | Permission | Access | | --- | --- | | Charges | Read | | Customers | Read | | Checkout Sessions | Read | | Subscriptions | Read | | Invoices | Read | | Webhook Endpoints | Write | The **Webhook Endpoints: Write** permission allows Ripples to automatically create a webhook in your Stripe account to receive payment events in real-time. 5. Click **"Create key"** and copy the key (it starts with `rk_live_` or `rk_test_`) ## Connect in Settings 1. Open your project dashboard in Ripples 2. Click the **Integrations** link in the header 3. Find the **Stripe** card and click **Connect** 4. Paste your restricted API key and click **Connect** Once connected, Ripples will: - Create a webhook endpoint in your Stripe account automatically - Begin importing your historical charges (backfill) - Start receiving new payment events in real-time ## Revenue Attribution Ripples uses a **3-tier fallback** to attribute each Stripe payment to a visitor: ### Tier 1: Checkout Metadata (Best) Pass the visitor ID directly in your Stripe Checkout session metadata. This gives the most accurate attribution because it directly links the payment to the browsing session. ```javascript // Next.js (Server-side) import { cookies } from "next/headers" import Stripe from "stripe" const stripe = new Stripe(process.env.STRIPE_SECRET_KEY) export async function createCheckout() { const cookieStore = await cookies() const visitorId = cookieStore.get("_rpl_vid")?.value const session = await stripe.checkout.sessions.create({ metadata: { ripples_visitor_id: visitorId }, // ... other checkout options }) } ``` ```javascript // Vanilla JavaScript (Client-side) // Get visitor ID from the Ripples SDK const visitorId = ripples.getVisitorId() // Send to your server when creating a checkout session const res = await fetch("/api/checkout", { method: "POST", body: JSON.stringify({ visitorId }), }) ``` ```php // Laravel (Server-side) use Stripe\StripeClient; $visitorId = $request->cookie('_rpl_vid'); // or from frontend: $request->input('visitorId') $stripe = new StripeClient(config('services.stripe.secret')); $session = $stripe->checkout->sessions->create([ 'metadata' => ['ripples_visitor_id' => $visitorId], // ... other options ]); ``` ### Tier 2: Email Matching (Automatic) If Tier 1 metadata is not present, Ripples matches the Stripe customer email to a visitor profile. This works when you have called `ripples.identify()` with the user's email. No extra setup needed — this happens automatically. ### Tier 3: Customer ID (Returning Customers) For subsequent payments, Ripples remembers the Stripe customer ID from previous attributions. If a customer was attributed once (via Tier 1 or 2), all future payments from that customer are automatically attributed to the same visitor. ## Events Tracked The following Stripe events are processed: | Stripe Event | Ripples Event | Description | | --- | --- | --- | | `charge.succeeded` | Charge Succeeded | One-time or first-time payment | | `charge.refunded` | Charge Refunded | Refund (negative revenue) | | `checkout.session.completed` | Checkout Completed | Checkout with Tier 1 attribution | | `invoice.paid` | Invoice Paid | Recurring subscription payment | | `customer.subscription.created` | Subscription Updated | New subscription | | `customer.subscription.updated` | Subscription Updated | Plan change / upgrade | | `customer.subscription.deleted` | Subscription Canceled | Subscription cancellation | ## MRR Tracking Ripples automatically calculates your **Monthly Recurring Revenue (MRR)** from Stripe subscriptions. Subscription amounts are extracted from webhook events and normalized to monthly (yearly plans are divided by 12, etc.). MRR updates automatically when subscriptions are created, upgraded, downgraded, or canceled. No configuration needed. ## Historical Backfill When you first connect Stripe, Ripples automatically imports all your historical charges and attributes them using the same 3-tier fallback. Active subscriptions are also synced to update visitor plan fields and populate MRR data. You can check the backfill status on the Integrations settings page. If needed, use the **Re-sync** button to clear and re-import all data. ## Deduplication Ripples automatically prevents duplicate revenue events: - Each Stripe charge, invoice, and checkout session is tracked by its unique ID - Webhook retries and backfill re-runs are automatically deduplicated - If you use the Stripe integration, you do **not** need to send revenue events from the frontend SDK — Stripe handles it automatically ## Disconnecting To disconnect the integration, go to Integrations settings and click **Disconnect** on the Stripe card. You can choose to keep or remove the imported revenue data. --- # Paddle Integration Automatically track revenue from Paddle and attribute it to the visitors who converted. ## Overview The Paddle integration connects your Paddle account to Ripples so you can: - See revenue attributed to each visitor and their acquisition source - Track transactions, subscriptions, and adjustments automatically - Backfill historical payment data when you first connect - Handle refunds and credits (negative revenue events are created automatically) Revenue events appear in the visitor timeline and contribute to each profile's lifetime revenue total. ## Prerequisites Before connecting Paddle, make sure you have: 1. **Persist mode enabled** — Ripples uses a cookie (`_rpl_vid`) to track visitors across sessions. This is the default behavior. 2. **User identification** — Call `ripples.identify()` when users sign in so we can match Paddle transactions to visitor profiles by email. ```javascript // Call this after login / signup (and on every authenticated page load) ripples.identify({ id: "user_123", // required email: "jane@example.com", // optional — used to match Paddle customers name: "Jane Doe", // optional avatar_url: "https://...", // optional signed_up_at: "2026-01-15T...", // optional — ISO 8601, enables cohort analysis plan: "pro", // optional }) ``` ## Create an API Key 1. Go to your [Paddle Dashboard → Developer Tools → Authentication](https://vendors.paddle.com/authentication) 2. Click **"Generate API key"** 3. Give it a name like `Ripples Analytics` 4. Set the following permissions: | Permission | Access | | --- | --- | | Notification settings | Read & Write | | Transactions | Read | | Subscriptions | Read | | Customers | Read | | Products | Read | The **Notification settings: Read & Write** permission allows Ripples to automatically create a webhook notification destination in your Paddle account to receive payment events in real-time. 5. Click **"Generate"** and copy the key (it starts with `pdl_`) ## Connect in Settings 1. Open your project dashboard in Ripples 2. Click the **Integrations** link in the header 3. Find the **Paddle** card and click **Connect** 4. Paste your API key and click **Connect** Once connected, Ripples will: - Create a notification destination in your Paddle account automatically - Begin importing your historical transactions (backfill) - Start receiving new payment events in real-time ## Revenue Attribution Ripples uses a **3-tier fallback** to attribute each Paddle payment to a visitor: ### Tier 1: Custom Data (Best) Pass the visitor ID in the `custom_data` field when creating a Paddle checkout or transaction. This gives the most accurate attribution because it directly links the payment to the browsing session. ```javascript // Paddle.js (Client-side) const visitorId = ripples.getVisitorId() Paddle.Checkout.open({ items: [{ priceId: "pri_..." }], customData: { ripples_visitor_id: visitorId }, }) ``` ```javascript // Next.js / Node.js (Server-side) import { cookies } from "next/headers" export async function createCheckout() { const cookieStore = await cookies() const visitorId = cookieStore.get("_rpl_vid")?.value // Pass to Paddle API when creating a transaction const transaction = await paddle.transactions.create({ items: [{ priceId: "pri_...", quantity: 1 }], customData: { ripples_visitor_id: visitorId }, }) } ``` ```php // Laravel (Server-side) $visitorId = $request->cookie('_rpl_vid'); // or from frontend: $request->input('visitorId') // Pass to Paddle API when creating a transaction $paddle->transactions->create([ 'items' => [['price_id' => 'pri_...', 'quantity' => 1]], 'custom_data' => ['ripples_visitor_id' => $visitorId], ]); ``` ### Tier 2: Email Matching (Automatic) If Tier 1 custom data is not present, Ripples matches the Paddle customer email to a visitor profile. This works when you have called `ripples.identify()` with the user's email. No extra setup needed — this happens automatically. ### Tier 3: Customer ID (Returning Customers) For subsequent payments, Ripples remembers the Paddle customer ID from previous attributions. If a customer was attributed once (via Tier 1 or 2), all future payments from that customer are automatically attributed to the same visitor. ## Events Tracked The following Paddle events are processed: | Paddle Event | Ripples Event | Description | | --- | --- | --- | | `transaction.completed` | Transaction Completed | Successful payment | | `adjustment.created` | Refund / Credit | Refund or credit (negative revenue) | | `subscription.created` | Subscription Updated | New subscription | | `subscription.updated` | Subscription Updated | Plan change / upgrade | | `subscription.canceled` | Subscription Canceled | Subscription cancellation | | `subscription.past_due` | Subscription Updated | Payment failed, subscription past due | ## MRR Tracking Ripples automatically calculates your **Monthly Recurring Revenue (MRR)** from Paddle subscriptions. Subscription amounts are extracted from webhook events and normalized to monthly (yearly plans are divided by 12, etc.). MRR updates automatically when subscriptions are created, upgraded, downgraded, or canceled. No configuration needed. ## Historical Backfill When you first connect Paddle, Ripples automatically imports all your historical completed transactions and attributes them using the same 3-tier fallback. Active subscriptions are also synced to update visitor plan fields and populate MRR data. You can check the backfill status on the Integrations settings page. If needed, use the **Re-sync** button to clear and re-import all data. ## Deduplication Ripples automatically prevents duplicate revenue events: - Each Paddle transaction and adjustment is tracked by its unique ID - Webhook retries and backfill re-runs are automatically deduplicated - If you use the Paddle integration, you do **not** need to send revenue events from the frontend SDK — Paddle handles it automatically ## Sandbox Support Ripples automatically detects Paddle sandbox API keys (containing `test_` or `sandbox` in the key) and routes requests to the Paddle sandbox environment. You can test the full integration flow without processing real payments. ## Disconnecting To disconnect the integration, go to Integrations settings and click **Disconnect** on the Paddle card. You can choose to keep or remove the imported revenue data. The notification destination in your Paddle account will be automatically removed. --- # PHP SDK Track revenue, signups, product usage, and user identity from your PHP backend using the official Ripples SDK. > Server-side tracking. Use the PHP SDK to send events from your backend — ideal for tracking payments processed server-side, webhooks, or any event that shouldn't go through the browser. ## Install ```bash composer require ripplesanalytics/ripples-php ``` Add your secret key to your `.env`: ``` RIPPLES_SECRET_KEY=priv_your_secret_key ``` Your secret key can be found in your Ripples dashboard under **Settings → API Keys**. ## Quick start ```php use Ripples\Ripples; $ripples = new Ripples(); // Track a payment $ripples->revenue(49.99, 'user_123'); // Track a signup $ripples->signup('user_123', ['email' => 'jane@example.com']); // Track product usage $ripples->track('created a budget', 'user_123', ['area' => 'budgets']); // Identify / update a user $ripples->identify('user_123', ['email' => 'jane@example.com']); ``` ## Track revenue Call `revenue()` whenever a payment is processed on your server: ```php $ripples->revenue(49.99, 'user_123'); ``` Pass extra context as a third argument. Any key that isn't a known field becomes a custom property automatically: ```php $ripples->revenue(49.99, 'user_123', [ 'email' => 'jane@example.com', 'currency' => 'EUR', 'transaction_id' => 'txn_abc123', 'name' => 'Pro Plan', 'plan' => 'annual', // custom property 'coupon' => 'WELCOME20', // custom property ]); ``` Refunds are negative revenue: ```php $ripples->revenue(-29.99, 'user_123', ['transaction_id' => 'txn_abc123']); ``` ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `amount` (required) | `float` | Revenue amount in your default currency. Use negative values for refunds. | | `user_id` (required) | `string` | Your internal user ID. | | `properties` (optional) | `array` | Additional properties: `email`, `currency`, `transaction_id`, `name`, plus any custom keys. | ## Track subscriptions (MRR) Call `subscription()` when a subscription is created, upgraded, downgraded, or canceled. This powers the **MRR** metric on your dashboard. > Stripe / Paddle users: MRR is tracked automatically via the integration. Only use this method if you use a payment provider without a native Ripples integration. ```php // User subscribes to Pro Monthly ($29/mo) $ripples->subscription('sub_123', 'user_456', 'active', 29.00, 'month', [ 'name' => 'Pro', 'currency' => 'EUR', ]); // User upgrades to Business Annual ($499/yr) $ripples->subscription('sub_123', 'user_456', 'active', 499.00, 'year', [ 'name' => 'Business', ]); // User cancels $ripples->subscription('sub_123', 'user_456', 'canceled', 0); ``` ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `subscriptionId` (required) | `string` | A stable identifier for the subscription (e.g. your database ID or payment provider's subscription ID). Must be the same across updates and cancellations. | | `userId` (required) | `string` | Your internal user ID. | | `status` (required) | `string` | One of: `active`, `canceled`, `past_due`, `trialing`, `paused`. | | `amount` (required) | `float` | Amount per billing cycle (e.g. `29.00`). Pass `0` when canceling. | | `interval` (optional) | `string` | Billing interval: `month` (default), `year`, `week`, or `day`. Yearly plans are automatically normalized to monthly for MRR. | | `attributes` (optional) | `array` | Additional properties: `currency` (3-letter code), `name` or `plan` (plan name), `interval_count` (e.g. `3` for quarterly). | ## Track product usage Call `track()` **only** for significant product usage — actions that prove a user got real value (created a budget, sent a message, invited a teammate). This is not a generic event log like PostHog or Mixpanel: do **not** send pageviews, banner impressions, button clicks, or "viewed X" events. Every `track()` call feeds the **Activation** dashboard, so noise here pollutes your funnel. ```php // Significant product usage — user created something $ripples->track('created a budget', 'user_123', [ 'area' => 'budgets', // optional: group into a product area ]); // User added their 10th transaction — we consider this their activation moment $ripples->track('added transaction', 'user_123', [ 'area' => 'transactions', 'activated' => true, // marks THIS occurrence as the activation moment, not every "added transaction" ]); ``` > How it works. Ripples auto-detects activation moments (first occurrence per user per action), computes adoption rates by product area, and correlates usage patterns with retention and payment. ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `actionName` (required) | `string` | What the user did. Be specific: `'created a budget'`, not `'budgets'`. | | `userId` (required) | `string` | Your internal user ID. | | `area` (optional) | `string` | Product area this action belongs to (e.g. `'budgets'`, `'reports'`). Groups actions in the dashboard. | | `activated` (optional) | `bool` | Set to `true` on the specific occurrence when activation happens for this user. This does not mark the event type as an activation event — it marks this particular moment as when the user activated. For example, `'sent message'` is a regular event, but when a user sends their 10th message you may consider that their activation moment and send that occurrence with `activated => true`. | ## Track signups Call `signup()` when a new user registers: ```php $ripples->signup('user_123', [ 'email' => 'jane@example.com', 'name' => 'Jane Smith', 'referral' => 'twitter', // custom property 'plan' => 'free', // custom property ]); ``` ### Laravel example ```php use Ripples\Ripples; class AuthController extends Controller { public function register(Request $request) { $user = User::create([...]); $ripples = new Ripples(); $ripples->signup($user->id, [ 'email' => $user->email, 'name' => $user->name, 'ripples_visitor_id' => $request->cookie('_rpl_vid'), ]); return $user; } } ``` > Tip: pass the visitor ID. Include ripples_visitor_id with the value from the _rpl_vid cookie to link this signup back to the anonymous browsing session and correct traffic source attribution. ## Identify users Call `identify()` to update user traits at any time — on login, plan change, profile update, etc.: ```php $ripples->identify('user_123', [ 'email' => 'jane@example.com', 'name' => 'Jane Smith', 'avatar_url' => 'https://example.com/avatars/jane.jpg', 'company' => 'Acme Inc', // custom property 'role' => 'admin', // custom property ]); ``` ### Properties | Key | Type | Description | | --- | --- | --- | | `email` (optional) | `string` | User's email address. Shown in the visitor profile and used for Stripe revenue attribution. | | `name` (optional) | `string` | Display name shown in the visitor profile. | | `avatar_url` (optional) | `string` | URL to the user's avatar image. Displayed in the visitor profile. Also accepted as `avatar`. | | `[custom]` (optional) | `string | number` | Any additional traits you want to attach to the user (e.g. `plan`, `company`, `role`). | ## Error handling ```php use Ripples\RipplesException; try { $ripples->revenue(49.99, 'user_123'); } catch (RipplesException $e) { // Log or handle gracefully — never block the user flow Log::warning('Ripples error: ' . $e->getMessage()); } ``` ## Configuration The SDK reads `RIPPLES_SECRET_KEY` from your environment automatically. You can also pass it explicitly: ```php $ripples = new Ripples('priv_explicit_key', [ 'base_url' => 'https://your-domain.com/api', // self-hosted 'timeout' => 10, // seconds (default: 5) ]); ``` The self-hosted URL can also be set via env: ``` RIPPLES_URL=https://your-domain.com/api ``` ### Configuration options | Option | Default | Description | | --- | --- | --- | | `base_url` | `https://api.ripples.sh` | API endpoint. Override for self-hosted deployments. | | `timeout` | `5` | HTTP request timeout in seconds. | ## Custom HTTP client Extend the class and override `post()` to use Guzzle, Symfony HttpClient, or any other HTTP library: ```php class MyRipples extends \Ripples\Ripples { protected function post(string $path, array $data): array { // your custom implementation using Guzzle, etc. } } ``` ## Requirements - PHP 8.1+ - `ext-curl` - `ext-json` --- # Python SDK Track revenue, signups, product usage, and user identity from your Python backend using the official Ripples SDK. > Server-side tracking. Use the Python SDK to send events from your backend — ideal for tracking payments processed server-side, webhooks, or any event that shouldn't go through the browser. ## Install ```bash pip install ripples ``` Add your secret key to your environment: ``` RIPPLES_SECRET_KEY=priv_your_secret_key ``` Your secret key can be found in your Ripples dashboard under **Settings → API Keys**. ## Quick start ```python from ripples import Ripples ripples = Ripples() # Track a payment ripples.revenue(49.99, "user_123") # Track a signup ripples.signup("user_123", email="jane@example.com") # Track product usage ripples.track("created a budget", "user_123", area="budgets") # Identify / update a user ripples.identify("user_123", email="jane@example.com") ``` Events are batched in memory and sent automatically when the process exits. ## Track revenue Call `revenue()` whenever a payment is processed on your server: ```python ripples.revenue(49.99, "user_123") ``` Pass extra context as keyword arguments. Any key that isn't a known field becomes a custom property automatically: ```python ripples.revenue(49.99, "user_123", email="jane@example.com", currency="EUR", transaction_id="txn_abc123", name="Pro Plan", plan="annual", # custom property coupon="WELCOME20", # custom property ) ``` Refunds are negative revenue: ```python ripples.revenue(-29.99, "user_123", transaction_id="txn_abc123") ``` ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `amount` (required) | `float` | Revenue amount in your default currency. Use negative values for refunds. | | `user_id` (required) | `str` | Your internal user ID. | | `**attributes` (optional) | `Any` | Additional properties: `email`, `currency`, `transaction_id`, `name`, plus any custom keys. | ## Track subscriptions (MRR) Call `subscription()` when a subscription is created, upgraded, downgraded, or canceled. This powers the **MRR** metric on your dashboard. > Stripe / Paddle users: MRR is tracked automatically via the integration. Only use this method if you use a payment provider without a native Ripples integration. ```python # User subscribes to Pro Monthly ($29/mo) ripples.subscription("sub_123", "user_456", "active", 29.00, "month", name="Pro", currency="EUR") # User upgrades to Business Annual ($499/yr) ripples.subscription("sub_123", "user_456", "active", 499.00, "year", name="Business") # User cancels ripples.subscription("sub_123", "user_456", "canceled", 0) ``` ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `subscription_id` (required) | `str` | A stable identifier for the subscription. Must be the same across updates and cancellations. | | `user_id` (required) | `str` | Your internal user ID. | | `status` (required) | `str` | One of: `active`, `canceled`, `past_due`, `trialing`, `paused`. | | `amount` (required) | `float` | Amount per billing cycle (e.g. `29.00`). Pass `0` when canceling. | | `interval` (optional) | `str` | Billing interval: `"month"` (default), `"year"`, `"week"`, or `"day"`. | | `currency` (optional) | `str` | 3-letter currency code (e.g. `"EUR"`). Defaults to project currency. | | `name` / `plan` (optional) | `str` | Plan name shown in the dashboard. | | `interval_count` (optional) | `int` | Billing frequency multiplier (e.g. `3` for quarterly). Default: `1`. | ## Track product usage Call `track()` **only** for significant product usage — actions that prove a user got real value (created a budget, sent a message, invited a teammate). This is not a generic event log like PostHog or Mixpanel: do **not** send pageviews, banner impressions, button clicks, or "viewed X" events. Every `track()` call feeds the **Activation** dashboard, so noise here pollutes your funnel. ```python # Significant product usage — user created something ripples.track("created a budget", "user_123", area="budgets") # User added their 10th transaction — we consider this their activation moment ripples.track("added transaction", "user_123", area="transactions", activated=True, # marks THIS occurrence as the activation moment ) ``` > How it works. Ripples auto-detects activation moments (first occurrence per user per action), computes adoption rates by product area, and correlates usage patterns with retention and payment. ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `action_name` (required) | `str` | What the user did. Be specific: `'created a budget'`, not `'budgets'`. | | `user_id` (required) | `str` | Your internal user ID. | | `area` (optional) | `str` | Product area this action belongs to (e.g. `'budgets'`, `'reports'`). Groups actions in the dashboard. | | `activated` (optional) | `bool` | Set to `True` on the specific occurrence when activation happens for this user. This does not mark the event type as an activation event — it marks this particular moment as when the user activated. For example, `'sent message'` is a regular event, but when a user sends their 10th message you may consider that their activation moment and send that occurrence with `activated=True`. | ## Track signups Call `signup()` when a new user registers: ```python ripples.signup("user_123", email="jane@example.com", name="Jane Smith", referral="twitter", # custom property plan="free", # custom property ) ``` ### Django example ```python from ripples import Ripples def register(request): user = User.objects.create_user(...) ripples = Ripples() ripples.signup(str(user.id), email=user.email, name=user.get_full_name(), ripples_visitor_id=request.COOKIES.get("_rpl_vid"), ) ripples.flush() return JsonResponse({"id": user.id}) ``` ### Flask example ```python from ripples import Ripples @app.route("/register", methods=["POST"]) def register(): user = create_user(...) ripples = Ripples() ripples.signup(str(user.id), email=user.email, name=user.name, ripples_visitor_id=request.cookies.get("_rpl_vid"), ) ripples.flush() return jsonify({"id": user.id}) ``` > Tip: pass the visitor ID. Include ripples_visitor_id with the value from the _rpl_vid cookie to link this signup back to the anonymous browsing session and correct traffic source attribution. ## Identify users Call `identify()` to update user traits at any time — on login, plan change, profile update, etc.: ```python ripples.identify("user_123", email="jane@example.com", name="Jane Smith", avatar_url="https://example.com/avatars/jane.jpg", company="Acme Inc", # custom property role="admin", # custom property ) ``` ### Properties | Key | Type | Description | | --- | --- | --- | | `email` (optional) | `str` | User's email address. Shown in the visitor profile and used for Stripe revenue attribution. | | `name` (optional) | `str` | Display name shown in the visitor profile. | | `avatar_url` (optional) | `str` | URL to the user's avatar image. Displayed in the visitor profile. Also accepted as `avatar`. | | `[custom]` (optional) | `str | int | float` | Any additional traits you want to attach to the user (e.g. `plan`, `company`, `role`). | ## Error handling By default, errors during flush are swallowed so your app is never disrupted by a Ripples outage. Use `on_error` to log them: ```python import logging ripples = Ripples(on_error=lambda e: logging.warning(f"Ripples error: {e}")) ``` You can also catch errors explicitly: ```python from ripples import RipplesError try: ripples.revenue(49.99, "user_123") ripples.flush() except RipplesError as e: # Log or handle gracefully — never block the user flow logging.warning(f"Ripples error: {e}") ``` ## Configuration The SDK reads `RIPPLES_SECRET_KEY` from your environment automatically. You can also pass it explicitly: ```python ripples = Ripples("priv_explicit_key", base_url="https://your-domain.com/api", # self-hosted timeout=10, # seconds (default: 3) ) ``` The self-hosted URL can also be set via env: ``` RIPPLES_URL=https://your-domain.com/api ``` ### Configuration options | Option | Default | Description | | --- | --- | --- | | `base_url` | `https://api.ripples.sh` | API endpoint. Override for self-hosted deployments. | | `timeout` | `3` | HTTP read timeout in seconds. | | `connect_timeout` | `2` | HTTP connection timeout in seconds. | | `max_queue_size` | `100` | Auto-flush when the queue reaches this size. | ## Flush manually Events are batched and sent automatically when the Python process exits (via `atexit`). For long-running processes, web servers, or CLI scripts, call `flush()` explicitly: ```python ripples.flush() ``` > Web frameworks. In Django or Flask, call flush() at the end of each request (or use middleware) to ensure events are delivered promptly. The auto-flush on exit works best for CLI scripts and short-lived processes. ## Custom HTTP client Subclass and override `_post()` to use `httpx`, `aiohttp`, or any other HTTP library: ```python class MyRipples(Ripples): def _post(self, path: str, data: dict) -> None: # your custom implementation using httpx, etc. pass ``` ## Requirements - Python 3.9+ - `requests` --- # iOS SDK Track product usage, screen views, and user identity from iOS, macOS, tvOS, and watchOS apps using the official Ripples Swift SDK. > Client-side tracking. Use the iOS SDK to send events directly from the device — pageviews (screen views), product usage, signups, and identify. Revenue should be tracked server-side via a payment integration or a server SDK. ## Install Add the package via Swift Package Manager: ```swift .package(url: "https://github.com/ripplesanalytics/ripples-ios", from: "0.1.6") ``` Or in Xcode: **File → Add Package Dependencies** and paste `https://github.com/ripplesanalytics/ripples-ios`. ## Keys Ripples projects have two identifiers. The iOS SDK takes the **project token**, never the secret key. | Key | Format | Where to use | Scope | | --- | --- | --- | --- | | Secret key | `priv_…` | Server-side only | Full ingest access, including `revenue` | | Project token | UUID | iOS / web / any client | `track`, `identify`, `signup`, `pageview` only | The project token is safe to bundle in a mobile or web app — revenue events submitted with the token are rejected server-side, so a scraped key can't forge MRR or LTV. Rotate it in project settings if you see abuse. > Never ship the priv_ key in a mobile or web app. ## Quick start Initialize once at app launch: ```swift import Ripples @main struct MyApp: App { init() { Ripples.setup(RipplesConfig(projectToken: "YOUR-PROJECT-TOKEN")) } var body: some Scene { WindowGroup { ContentView() } } } ``` ## Identify users Call `identify` to associate the visitor with your internal user ID and update traits. Traits are cached and forwarded with every subsequent event automatically. ```swift Ripples.shared.identify("user_123", traits: [ "email": "jane@example.com", "name": "Jane Smith", ]) ``` ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `userId` (required) | `String` | Your internal user ID. | | `traits` (optional) | `[String: Any]` | User traits: `email`, `name`, `avatar_url`, plus any custom keys (`plan`, `company`, `role`, …). | ## Track product usage Call `track` **only** for significant product usage — actions that prove a user got real value (created a budget, sent a message, invited a teammate). This is **not** a generic event log like PostHog or Mixpanel: do **not** send screen views, banner impressions, button taps, or "viewed X" events. Every `track` call feeds the **Activation** dashboard, so noise here pollutes your funnel. (Use `screen(_:)` / `trackScreen` for navigation — see below.) ```swift // Group actions by product area Ripples.shared.track("created a budget", area: "budgets") // Mark THIS occurrence as the activation moment Ripples.shared.track("added transaction", area: "transactions", properties: [ "activated": true, ]) // area can also be passed inside properties — both forms are equivalent Ripples.shared.track("exported report", properties: ["area": "reports"]) ``` > How it works. Ripples auto-detects activation moments (first occurrence per user per action), computes adoption rates by product area, and correlates usage patterns with retention and payment. ### Keeping user data fresh Pass `userProperties` alongside any event to upsert the user record without a separate `identify` call. This mirrors how PostHog handles `$set` — one request carries both the event and the trait update: ```swift Ripples.shared.track("created a budget", area: "budgets", userProperties: ["plan": "pro", "company": "Acme"]) ``` Traits from the last `identify` call are cached and forwarded automatically with every subsequent event, so you generally don't need to pass `userProperties` explicitly. Pass an empty dictionary `[:]` to suppress forwarding for a specific call. ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `actionName` (required) | `String` | What the user did. Be specific: `"created a budget"`, not `"budgets"`. | | `area` (optional) | `String` | Product area this action belongs to (e.g. `"budgets"`, `"reports"`). Groups actions in the dashboard. | | `properties` (optional) | `[String: Any]` | Extra event properties. Pass `"activated": true` on the specific occurrence when activation happens for this user. | | `userProperties` (optional) | `[String: Any]` | User trait upsert sent with this event. Pass `[:]` to suppress the cached-traits forwarding for this call. | ## Track screen views Screen views are stored as `pageview` events and appear in the **Pages** report alongside web pageviews. The first screen in each session is automatically flagged as the session entry. Use the SwiftUI modifier — one line per screen: ```swift struct HomeView: View { var body: some View { List { ... } .trackScreen("Home") } } // With area and extra properties struct ListDetailView: View { let listId: String var body: some View { ScrollView { ... } .trackScreen("ListDetail", properties: ["area": "lists", "list_id": listId]) } } ``` Or call it imperatively (UIKit, custom navigation): ```swift Ripples.shared.screen("Settings") Ripples.shared.screen("ListDetail", area: "lists", properties: ["list_id": listId]) ``` ## Flush manually Events are batched in memory, persisted to disk, and flushed automatically on a timer, when the queue fills, and when the app enters background or terminates. Call `flush` explicitly before logout, account deletion, or any moment you want delivery to be attempted immediately: ```swift Ripples.shared.flush { /* delivery attempted */ } ``` ## Automatic behaviour | What | How | | --- | --- | | Persistent visitor ID | Generated once, stored to disk, survives reinstalls. | | Session ID | New UUID on SDK init; rotates after 30 min in background. | | Device & OS metadata | Collected once at startup, merged into every event automatically. | | Geo / country | Resolved server-side from the request IP via Cloudflare headers. | | Offline queuing | Events persisted to disk; flushed when connectivity returns. | | Background flush | Queue flushed on `didEnterBackground` and `willTerminate`. | | Retry / backoff | 5xx and network errors back off exponentially (5s → 5 min). | | Poison batch protection | Non-retryable 4xx drops the batch so a bad payload can't wedge the queue. | ## Configuration ```swift let config = RipplesConfig(projectToken: "YOUR-PROJECT-TOKEN") config.host = "https://your-domain.com" // self-hosted config.flushIntervalSeconds = 30 config.flushAt = 20 config.maxBatchSize = 50 config.maxQueueSize = 1000 config.requestTimeout = 10 config.onError = { error in print("Ripples error:", error) } Ripples.setup(config) ``` ### Configuration options | Option | Description | | --- | --- | | `host` | API endpoint. Override for self-hosted deployments. | | `flushIntervalSeconds` | How often the timer attempts to flush the queue. | | `flushAt` | Auto-flush as soon as the queue reaches this many events. | | `maxBatchSize` | Maximum events sent in a single HTTP request. | | `maxQueueSize` | Hard cap on the on-disk queue. Oldest events are dropped past this. | | `requestTimeout` | HTTP request timeout in seconds. | | `onError` | Closure invoked on delivery failure. Wire this to your logging or Sentry. | ## Requirements - Swift 5.5+ - iOS 13+, macOS 10.15+, tvOS 13+, watchOS 6+