# Welcome to Ripples Metrics Lightweight analytics for indie products. One script tag. No complex setup. Free until $1K MRR. ## What is Ripples Metrics? Ripples Metrics (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.milestone()](/docs/milestones)` | Record a lifecycle moment counted once per person (onboarding completed, paywall viewed, demo requested), attributed to how they were acquired | | `[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 (see [Preserving attribution](/docs/attribution)) | --- # Installation Add Ripples Metrics 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. | | `data-app-name` (optional) | Native app name to display instead of the WebView browser name. Read it from the native bundle at runtime; for Capacitor, use `App.getInfo().name`. | | `data-app-version` (optional) | Bundled native app version to display instead of the WebView browser version. For Capacitor, use `App.getInfo().version`. | | `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 in Capacitor, read the app name and bundled version before appending the tag. Ripples maps them to the same browser / browser_version fields used by the native iOS SDK: ```typescript import { App } from '@capacitor/app'; import { Capacitor } from '@capacitor/core'; const app = await App.getInfo(); 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'; script.dataset.appName = app.name; script.dataset.appVersion = app.version; 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()`. - **Lifecycle moments** — email verified, onboarding completed, paywall viewed, demo requested, MQL. You count those once per person on a funnel, not as usage: send them with [`milestone()`](/docs/milestones). - **Billing events** — payment failed, refunds, renewals. Those belong to [revenue and subscriptions](/docs/php-sdk#track-subscriptions), not to usage. 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. --- # Track Companies (B2B) Tell Ripples which company each user works in, so a B2B product can count active companies, not just active users. In a B2B product the customer is a company, not a person. Five people from one account using you every day is one healthy customer; five people from five accounts who each logged in once is five at risk. Company tracking lets Ripples tell those apart. You make one call, `group()`, saying which company a user works in. Ripples remembers it, and the user's events count for that company from then on. > Company reports are on the way. Ripples stores company data from the moment you send it, so the history is there when the company views arrive. ## ripples.group() Call it after `identify()`, wherever you know which company the signed-in user works in: ```javascript ripples.identify({ id: user.id, email: user.email }) ripples.group(user.companyId, { name: 'Acme Corp', // shown instead of the id plan: 'business', seats: 25, }) ``` Every pageview, `track()` and `identify()` from this browser now counts for Acme Corp, across page loads, until you change it. Like `identify()`, it is safe to call on every page load: it is only sent again when something changes, or once per session. > Want the signup itself counted for the company? Call group() before the identify() that signs the user up. Events only carry a company set before them. ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `companyId` (required) | `string | number` | Your own unique id for the company, such as the primary key of your accounts or teams table. Not its name: two companies can share a name. Up to 255 characters. | | `traits` (optional) | `object` | Traits of the company (not of the user): `name`, plan, seats, industry. Merged into what Ripples already has: a key you send again is overwritten, a key you leave out is kept. `name` is what the dashboard shows. | ### Users in several companies A consultant or an agency may work in several companies. Call `group()` again when they switch: ```javascript // The user opens another client's workspace ripples.group('globex_7', { name: 'Globex' }) ``` Events already sent keep the company they were sent with, so the switch never rewrites history. Ripples records the user as a member of both. ## ripples.resetGroup() Stops attaching the company to events, for example when the user leaves it or switches to a personal workspace: ```javascript ripples.resetGroup() ``` `ripples.reset()`, which you call on logout, clears it too. ## Server-side and mobile From your server, pass the user as well. One call is enough: Ripples keeps who works where, so the user's later events count for their company without passing anything on each call. ```php // PHP: at signup, when they join or switch company, when the plan changes $ripples->group($user->id, $team->id, ['name' => $team->name, 'plan' => 'business']); $ripples->track('created a report', $user->id); // counts for $team ``` ```python # Python ripples.group(user.id, team.id, name=team.name, plan="business") ripples.track("created a report", user.id) # counts for team ``` Pass `null` / `None` as the user to update a company's traits without touching anyone's membership, for example from a nightly sync of your accounts. The iOS SDK works like the web one: `Ripples.shared.group(team.id, traits: ["name": team.name])` sticks until you change it or call `resetGroup()`. See the [PHP](/docs/php-sdk#companies), [Python](/docs/python-sdk#companies) and [iOS](/docs/ios-sdk#companies) pages. ### HTTP API Send a `group` event to `POST /v1/ingest/batch`: ```json { "events": [ { "$type": "group", "$user_id": "user_123", "$company_id": "acme_42", "$traits": { "name": "Acme Corp", "plan": "business" } } ] } ``` Any other event may carry `$company_id` to say which company it happened in, overriding the user's current one. Only the `$`-prefixed name is read this way: a plain `company_id` stays one of your custom properties. ## How it works 1. `group()` creates the company if it is new, merges its traits, and records the user as a member. 2. Every event is filed under a company: the one it names (the web and iOS SDKs name the one you set), otherwise the company its user was most recently active in. Events from anonymous visitors who never called `group()` belong to none. 3. A company that appears on events gets a record even if you never sent traits, with the dates of its first and latest activity. 4. Payments need no company data of their own: a company's revenue is worked out through the people who paid, so Stripe and Paddle payments count too. ## Tips - **Use stable ids**, never names or domains that can change. - **Send `name`** in the traits so the dashboard shows "Acme Corp" rather than `acme_42`. - **Company traits describe the company.** Things about the person, like their role, belong in `identify()`. - **Coming from Segment?** This is Segment's `group` call: the same idea, one argument shorter. --- # 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. `role`). To tie users to the company they work in, use [`ripples.group()`](/docs/companies) instead of a trait. | ## 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" ``` Pass it to your backend on signup or checkout, store it with the user, and hand it to server-side `signup()` / `identify()` calls as `visitor_id`. That is what links a server-created user back to the browsing session that produced them and keeps the traffic source: ```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, then pass it to // server-side calls as 'visitor_id' (or let the PHP SDK read the cookie) $user->ripples_visitor_id = $request->ripples_visitor_id; $ripples->signup($user->id, ['email' => $user->email, 'visitor_id' => $user->ripples_visitor_id]); ``` > Signups that finish in another browser lose their source without this. An ad tapped in the Facebook or Instagram in-app browser and a confirmation email opened in Safari are two different visitors. Only the visitor ID captured at the signup form can tie the account to the ad. See Preserving attribution for the full flow. > 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. Different browsers on the same phone do not share it. ## ripples.reset() Call it when the user logs out. It forgets the signed-in user and starts the browser over as a new anonymous visitor, so whoever uses it next is not linked to them. ```javascript async function logout() { await fetch('/logout', { method: 'POST' }) ripples.reset() } ``` `reset()` also clears the company set with [`ripples.group()`](/docs/companies). To stop tracking a browser altogether, or to erase a user who deleted their account, see [Delete user data](/docs/privacy) (`ripples.optOut()` and the deletion API). ## 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. --- # Track Milestones Record the moments on the way to revenue, such as MQL, demo held or onboarding completed, counted once per person and attributed to how they were acquired. Ripples already knows three moments in a person's life: they **signed up**, they **activated**, they **paid**. A milestone is any other moment your business counts the same way: a lead became an MQL, a demo was held, onboarding was completed, the paywall was seen for the first time. Each milestone is: - **counted once per person**, however many times you send it; - **dated by when the person first reached it**, so "MQLs in September" means people who became an MQL in September; - **attributed to how the person was acquired**, so "MQLs from Paid Search" needs no extra work; - **kept forever**, and never counted as product usage: it does not feed activation, retention or "last seen". ## Milestone or track? > Would you put it on a funnel next to *signed up* and *paid*, and count each person once? Use `milestone()`. > > Do people do it repeatedly as part of using the product? Use [`track()`](/docs/custom-events). `demo_held`, `mql` and `onboarding_completed` are milestones. `created a report` and `sent a message` are product usage. Billing events (`payment_failed`, refunds, renewals) are neither: they describe money, not a person's progress, and are refused as milestones. A milestone is a fact, not a state. "Became an MQL on 14 September" stays true if the lead is disqualified later. To record the current state, use a trait: `identify(userId, { lifecycle_stage: 'sql' })`. ## ripples.milestone() From the browser (tracker v1.10.0 and later): ```javascript ripples.milestone("onboarding_completed", { steps: 4 }) ripples.milestone("paywall_viewed", { placement: "export" }) ``` It always belongs to this browser's person: the signed-in user, or the anonymous visitor before `identify()`, whose milestones move with them when they sign up. The function-call style works too: `ripples("milestone", "paywall_viewed")`. ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `name` (required) | `string` | snake_case, starting with a letter, up to 64 characters (`^[a-z][a-z0-9_]{0,63}$`). Use a [recommended name](#recommended-names) when one fits. | | `properties` (optional) | `object` | Any context worth keeping (source, plan, seller). Every property is stored. | ## From your server Sales stages, demos and anything your backend or CRM decides belong on the server. Date them by when they happened, not when you send them: ```php // PHP $ripples->milestone('mql', userId: (string) $user->id, properties: ['source' => 'hubspot'], timestamp: $changedAt); ``` ```python # Python ripples.milestone("mql", str(user.id), timestamp=changed_at, source="hubspot") ``` ```swift // iOS (0.1.17 and later) Ripples.shared.milestone("notifications_enabled") ``` See [PHP SDK](/docs/php-sdk#milestones), [Python SDK](/docs/python-sdk#milestones) and [iOS SDK](/docs/ios-sdk#milestones). ### HTTP `POST /v1/ingest/batch` with your secret key: ```json { "events": [ { "$type": "milestone", "$name": "demo_held", "$user_id": "42", "$sent_at": "2026-09-14T10:00:00Z", "$event_id": "b2f0c9e4-8a1d-4f3e-9c55-1d2e3f4a5b6c", "seller": "anna" } ] } ``` Any key that is not a `$` system field is a property, or put them under `$properties`. A refused milestone does not fail the batch; it comes back in the response: ```json { "ok": true, "rejected": [{ "event_id": "b2f0c9e4-...", "reason": "reserved_name" }] } ``` | Reason | Meaning | | --- | --- | | `missing_name` | No `$name`. | | `invalid_name` | Not snake_case (`^[a-z][a-z0-9_]{0,63}$`). | | `reserved_name` | A built-in moment (`first_visit`, `signed_up`, `activated`, `trial_started`, `first_paid`, `churned`), which Ripples derives itself, or a billing event (`payment_failed`, refunds, renewals). | | `missing_identity` | None of `$user_id`, `$visitor_id` or `$email`. | ## Whose milestone is it? A milestone needs a person. In order of strength: 1. **`$user_id`**: your user. The usual case. 2. **`$visitor_id`**: a browser visitor with no account yet. Forward the tracker's `_rpl_vid` cookie (the PHP SDK reads it automatically under PHP-FPM; see [Preserve attribution](/docs/attribution)). When that browser later signs up, the milestone moves to the account. 3. **`$email`**: a lead you only know by email, such as a demo request from a landing form. Ripples keeps one person per email, so repeated sends converge. ### Leads without an account ```php // A demo request from the public site: no account, maybe a cookie $ripples->milestone('demo_requested', email: $form->email, properties: ['company_size' => $form->size]); ``` When you later identify the user with the same email, from any SDK, the lead and everything it collected merge into the account: ```php $ripples->identify((string) $user->id, ['email' => $user->email]); ``` > Send an email only once you trust it. Merging on email attaches the lead's history to whoever owns that address. Send one the person verified, or one your CRM or sales team confirmed. A typed, unconfirmed form email can belong to someone else. A lead known only by email has no browser, so it reads as "API" until it merges with a person who has one. Forward `$visitor_id` as well when you have it, and the lead keeps the channel that brought them. ## Recommended names Any snake_case name works (`exam_passed` is fine). These names get a label and a default position in funnels, and let reports recognise the same moment across projects. | Group | Name | Typical source | | --- | --- | --- | | Lead (B2B) | `lead_captured` | Waitlist, newsletter, lead magnet, quiz-funnel email step | | | `demo_requested` | Demo or contact-sales form | | | `demo_booked` | Calendar booking | | | `demo_held` | CRM: meeting completed or logged | | | `mql` | CRM lifecycle stage: marketing qualified | | | `sql` | CRM lifecycle stage: sales qualified or opportunity | | Account | `email_verified` | Confirm-email link | | | `onboarding_completed` | Last onboarding step, or first core object created | | | `integration_connected` | First integration (calendar, mailbox, Health, App Store Connect) | | | `teammate_invited`, `teammate_joined` | B2B expansion | | | `invite_sent`, `invite_accepted` | Referral and sharing loops | | Money | `paywall_viewed` | First paywall or locked page | | | `checkout_started` | Payment form opened | | Mobile | `notifications_enabled`, `tracking_authorized` | iOS permission prompts | ## Repeats Send a milestone as often as it happens. CRM webhooks re-fire, and a lead can go MQL, back to lead, and MQL again. Every occurrence is stored; reports count the person **once, on the day of their first occurrence ever**. Someone who became an MQL in August and again in September counts in August only. The person page shows the first and last time and how many. ## Funnels The **Funnel** tab follows people through milestones in order: of everyone who first reached the first step in a period, how many reached each next step after the one before, within a conversion window (30 days by default). Steps are *first visit*, *signed up*, *activated*, *first paid* and any milestone you send. Without changes it reads your milestone order from **Settings › Milestones**; add, remove or reorder steps on the page and share the link. Your AI assistant gets the same funnel from `ripples_funnel` with `steps`, for example `["signed_up", "mql", "demo_held", "first_paid"]`. ## Example: HubSpot lifecycle stage to MQL / SQL Subscribe your HubSpot app to `contact.propertyChange` for the `lifecyclestage` property, and send the change as a milestone dated by HubSpot's `occurredAt`: ```php // POST /webhooks/hubspot (verify HubSpot's signature first) $stages = [ 'marketingqualifiedlead' => 'mql', 'salesqualifiedlead' => 'sql', ]; foreach ($request->json()->all() as $change) { $milestone = $stages[$change['propertyValue']] ?? null; if ($change['propertyName'] !== 'lifecyclestage' || $milestone === null) { continue; } $email = $hubspot->contactEmail($change['objectId']); // your HubSpot API call $user = User::where('email', $email)->first(); $ripples->milestone( $milestone, userId: $user ? (string) $user->id : null, email: $user ? null : $email, // a lead with no account yet properties: ['source' => 'hubspot', 'stage' => $change['propertyValue']], timestamp: (new DateTimeImmutable())->setTimestamp(intdiv($change['occurredAt'], 1000)), ); } ``` If your portal uses custom stages, map their ids instead of the built-in names. Ripples records your CRM's verdict; the qualification rules stay in HubSpot. To import history, send one milestone per person and stage with the date the contact entered it (HubSpot's `hs_v2_date_entered_` properties). ## Best practices - **Pick a handful.** Three to six milestones that mark real progress beat twenty that mark clicks. - **Send from the source of truth.** CRM stages and demos from the server, permission prompts and paywalls from the client. - **Date them.** Pass the timestamp of when it happened, especially for webhooks and imports. - **Never use a milestone as the activation event.** Activation is product usage, set with `track(..., { activated: true })`. --- # Preserving Attribution Why a signup can lose its traffic source, and how passing the visitor ID to signup() and identify() keeps it. > The one rule: attribution belongs to the visitor ID, not to the user. Whenever your server tells Ripples about a new user, it must also say which visitor that user was. Otherwise the user is born on a blank profile and the ad, post or search that brought them in is never credited. ## Where attribution lives Every browser that loads the Ripples script gets a visitor ID, stored in the `_rpl_vid` cookie (root domain, 2 years) with a `localStorage` fallback. The first pageview from that visitor records its **first touch**: referrer, UTM parameters and ad click IDs (`gclid`, `fbclid`, `ttclid` and so on), which Ripples resolves into a channel, a source and a campaign. That snapshot is attached to the anonymous visitor. A user only inherits it when Ripples can connect the user ID to that visitor ID. There are exactly two ways to make that connection: 1. **In the browser:** `ripples.identify({ id })` runs in the same browser that did the browsing. The anonymous profile adopts the user ID and keeps its first touch. Nothing else to do. 2. **From your server:** `signup()` or `identify()` is called with both `user_id` and the browser's `visitor_id`. Ripples finds the anonymous profile behind that visitor ID and links the user to it. A server-side call that names the user but not the visitor cannot use either path. Ripples creates a profile for the user with no browsing history and no source. The anonymous visitor that saw the ad stays anonymous, and nothing that happens later can tell the two apart. ## How a signup loses its source The common case is a signup that starts in one browser and finishes in another. Take a Facebook ad: | Step | What the user does | What Ripples sees | | --- | --- | --- | | 1 | Taps your ad. It opens in the Facebook in-app browser. | Visitor A is created. First touch: Paid Social / Facebook, with the `fbclid`. | | 2 | Fills in the signup form and submits it. | Your backend creates the account and calls `signup(user_id)` without `visitor_id`. Ripples creates a user profile with no source. Visitor A stays anonymous. | | 3 | Opens the confirmation email. The link opens in Safari (or Chrome), the system browser. | Safari has its own cookies, so this is a brand-new visitor B. First touch: Direct (or Email, if the link carries UTMs). | | 4 | Lands in the app, logged in. | `ripples.identify(user_id)` merges visitor B into the user profile. The profile had no first touch, so it adopts B's: Direct. The Facebook ad is credited with nothing. | Nothing about this is specific to Facebook. Instagram, TikTok, LinkedIn, X, Gmail and Slack all open links in their own in-app browsers. Any email confirmation, magic link, "continue on desktop" flow or password manager that opens a different browser produces the same split. The same loss happens in a single browser when the user never comes back to it: if `identify()` never runs where the browsing happened, the anonymous visitor is never claimed. > How to spot it in your dashboard: signups attributed to Direct whose first session starts on the confirmation or login URL, and people whose profile shows source API with zero pageviews. A large Direct share on a product that only runs paid campaigns is usually this. Ripples repairs the most common form of this split on its own. When a signup's first touch is Direct or Email and an anonymous session on the **same device** (same OS version, model, screen, language and country) ended within 15 minutes of it, that session is folded into the person and its source becomes the first touch. The match has to be unambiguous: if two anonymous sessions fit, nothing happens. People rescued this way carry a "probable" badge on their acquisition block in the visitor inspector. It covers the phone-only flow above; it cannot cover a signup that moves between devices, and it never overrides a real source. Passing the visitor ID, below, remains the reliable path. ## The fix: carry the visitor ID across Capture the visitor ID **in the browser that submitted the signup form**, store it with the pending signup, and pass it to `signup()` or `identify()` when you create the account, whenever and wherever that happens. Two ways to get hold of it: - **Same-site request** (form POST or `fetch` to your own domain or a subdomain): the `_rpl_vid` cookie is sent automatically. Read it on the server. - **Cross-origin API or native wrapper**: put `ripples.getVisitorId()` in the request body. ```javascript await fetch('https://api.example.com/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, ripples_visitor_id: ripples.getVisitorId(), // any field name; it's your API }), }) ``` Where the ID has to come from depends on when the account is actually created: | When you create the account | Where the visitor ID comes from | | --- | --- | | In the request that handles the form submit | The `_rpl_vid` cookie of that request, or the value the page sent in the body. | | When the confirmation link is clicked | Not the cookie of the browser that opened the link: that is visitor B. Save the ID at form time, on the pending signup row or inside the signed token in the link, and pass that one. | | In a queue job or webhook later | The ID you stored. There is no request cookie in a worker. | Once the account is linked to visitor A, the rest is automatic. When the user lands in Safari and your app calls `ripples.identify()`, visitor B is merged into the same person as a second device. The profile keeps A's first touch because it is the earlier one, and both sessions show up on the same person. > JS-only setups: if you have no server-side integration, call ripples.identify({ id }) as soon as your API returns a user ID, in the browser where the form was submitted, before the email is confirmed. That claims visitor A on the spot. The confirmation browser then merges in as a second device. ## Passing the ID with each SDK ### PHP SDK On a normal web request the [PHP SDK](/docs/php-sdk) reads `_rpl_vid` from `$_COOKIE` by itself, so a `signup()` call inside the request that created the account is already attributed: ```php $ripples->signup($user->id, [ 'email' => $user->email, 'name' => $user->name, ]); ``` Pass `visitor_id` explicitly when the ID was stored earlier, and use `setVisitorId()` in a queue worker, where the SDK deliberately ignores `$_COOKIE`: ```php // Confirmation handler: the cookie here belongs to the wrong browser $ripples->signup($user->id, [ 'email' => $user->email, 'visitor_id' => $pending->ripples_visitor_id, ]); // Queue job $ripples->setVisitorId($user->ripples_visitor_id); $ripples->signup($user->id, ['email' => $user->email]); ``` > Laravel: read the cookie with $_COOKIE['_rpl_vid'], not $request->cookie('_rpl_vid'). Laravel's cookie encryption middleware returns null for cookies it did not encrypt itself. Alternatively add _rpl_vid to the encryptCookies(except: [...]) list in bootstrap/app.php. ### Python SDK The [Python SDK](/docs/python-sdk) does not read cookies on its own. Bind the visitor once per request before any call, or pass it on the call: ```python from ripples import Ripples, set_visitor_id, visitor_id_from_cookies ripples = Ripples() def register(request): set_visitor_id(visitor_id_from_cookies(request.COOKIES)) # Django; Flask: request.cookies user = User.objects.create_user(...) ripples.signup(str(user.id), email=user.email) ripples.flush() ``` ```python # Confirmation handler or Celery task: pass the stored ID ripples.signup(str(user.id), email=user.email, visitor_id=pending.ripples_visitor_id, ) ``` ### REST API Both ingest endpoints accept an optional `visitor_id`: ```bash curl -X POST https://api.ripples.sh/v1/ingest/signup \ -H "Authorization: Bearer priv_XXXX" \ -H "Content-Type: application/json" \ -d '{ "user_id": "user_123", "email": "jane@example.com", "visitor_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" }' ``` `POST /v1/ingest/identify` takes the same field. The value must be the UUID from the cookie, unchanged. ### JS SDK Nothing to pass. `ripples.identify()` in the browser is the link. Call it on every authenticated page load so every browser the user logs in from is folded into the same person. See [Identify users](/docs/identify). ## Example: email confirmation flow A Laravel app that creates the account at form submit and confirms the email afterwards: ```php // POST /register, in the browser that saw the ad public function register(Request $request) { $user = User::create([ 'email' => $request->email, 'password' => Hash::make($request->password), 'ripples_visitor_id' => $_COOKIE['_rpl_vid'] ?? null, // keep it for later calls ]); // Same request: the SDK picks up the cookie, the user inherits the ad click (new Ripples())->signup($user->id, ['email' => $user->email]); $user->sendEmailVerificationNotification(); } ``` ```javascript // The page the confirmation link lands on, in Safari ripples.identify({ id: user.id, email: user.email, signed_up_at: user.created_at }) // Safari's visitor merges into the same person; the Facebook first touch stays ``` If your app creates the account only when the link is clicked, move the cookie read to the form handler, store the ID with the pending signup, and pass it as `visitor_id` in the confirmation handler. Every later server-side call about this user (`identify()`, `track()`, `revenue()`) can omit it: once the user exists, Ripples resolves their profile by `user_id`. ## Where the first touch shows up: cohorts The Cohorts page can key its rows on either of two dates, with a **Cohort by** switch in the toolbar: - **Signup date** (default): a person sits on the row of the period they signed up in, and D0 is their signup day. - **First touch**: a person sits on the row of the period they first arrived in, the ad click for paid traffic, and D0 is that day. Ad spend is always booked on the click date. Under **Signup date** a Jun 1 click that signs up on Jun 10 lands on the Jun 10 row, next to Jun 10 spend, so ROAS, CPR and CAC compare spend with people it did not buy whenever there is a click-to-signup lag. Under **First touch** that person lands on the Jun 1 row, next to the spend that bought them, and CR1 becomes exactly Signups ÷ New visitors. The trade-off is that a recent first-touch row keeps gaining signups and revenue for as long as your lag runs, so read its later columns as still open. Both views depend on the first touch being preserved as described above: a signup that arrived on a blank profile has no click to be anchored on and falls back to its signup date. The same switch is available to the MCP `ripples_cohorts` tool as `anchor: "first_touch"`. ## App installs An App Store install arrives with no referrer and no UTM tags, so the app's first screen is Direct even when an ad won the install. The ad network knows which campaign it was, and can tell your app: send that answer to Ripples and it becomes the person's first touch, exactly like a landing with UTM tags. See [Apple Ads](/docs/apple-ads) for the endpoint, the webview call `ripples.attribution()` and how it decides between the install and an earlier visit. ## Checklist - The visitor ID is read in the request that handled the **signup form**, never in the request that handled the **confirmation link**. - Server-side `signup()` and `identify()` calls made outside that request pass the stored `visitor_id`. - Queue workers use `setVisitorId()` (PHP) or `set_visitor_id()` / `visitor_id=` (Python). - The browser calls `ripples.identify()` after login, on every authenticated page load. - Confirmation and magic-link URLs do not carry `utm_*` parameters that would label the second browser as a campaign of its own. - In the dashboard, a fresh test signup made from an ad or UTM link shows that channel on the person, not Direct. --- # 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 Metrics` 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 ]); ``` ### Your own user id If you already know the user when you create the checkout session or the subscription, put your user id in its metadata as `ripples_user_id` (the same id you pass to `identify()`). It beats every other tier: it still matches when the billing email differs from the login email (a `finance@` address on a company card), and it carries over to every renewal, which `ripples_visitor_id` cannot do because invoices don't carry checkout metadata. ```php $stripe->subscriptions->create([ 'customer' => $customerId, 'items' => [['price' => $priceId]], 'metadata' => ['ripples_user_id' => (string) $user->id], ]); ``` Ripples reads it from charges, checkout sessions, subscriptions and invoices (including the subscription metadata Stripe copies onto each invoice). ### 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 | One negative revenue row per refund, for that refund's amount (partial refunds included) | | `checkout.session.completed` | Checkout Completed | Checkout with Tier 1 attribution | | `invoice.paid` | Invoice Paid | Recurring subscription payment | | `customer.subscription.created` | (no event) | New subscription: updates MRR, plan and subscription status | | `customer.subscription.updated` | (no event) | Plan change / upgrade: updates MRR and plan | | `customer.subscription.deleted` | (no event) | Cancellation: removes it from MRR, marks the customer churned | ## 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. Subscriptions in a trial are not counted in MRR until they turn active; they are shown beside it. Each subscription is converted to your project currency. ## One Stripe Account, Several Products A single Stripe account often bills more than one product — two apps sold from the same account, or a product that has since moved to its own account but left its old subscriptions renewing on the first one. By default Ripples books every payment on the account against the project that connected the key, so the other product's revenue and MRR show up here. Tell Ripples which products are not yours: 1. Open **Integrations** settings and click **Products** on the connected Stripe card 2. Paste the Stripe **price** or **product** ids that belong to another project — one per line 3. Click **Save** Their charges, checkouts, invoices and subscriptions are skipped from then on, so they never reach this project's revenue, its subscription/one-time split, or its MRR. A price id covers that one price; a product id covers every price under it. A payment that mixes an ignored product with one of yours is still recorded in full — Ripples never drops revenue it cannot cleanly separate. This applies to new events only. Use **Re-sync** afterwards to re-import historical payments with the filter applied. ## Historical Backfill When you first connect Stripe, Ripples automatically imports all your historical charges and refunds and attributes them using the same 3-tier fallback, so a refunded payment nets to zero. 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, checkout session and refund 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 Metrics` 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`, `adjustment.updated` | Refund / Credit | Refund or credit (negative revenue), recorded once Paddle approves it. A pending refund is not counted; a rejected or reversed one is put back. | | `subscription.created` | (no event) | New subscription: updates MRR, plan and subscription status | | `subscription.updated` | (no event) | Plan change / upgrade: updates MRR and plan | | `subscription.canceled` | (no event) | Cancellation: removes it from MRR, marks the customer churned | | `subscription.past_due` | (no event) | Payment failed: stays in MRR as 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', 'signed_up_at' => $user->created_at->toIso8601String(), ]); ``` ## 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. Give each refund **its own** `transaction_id` (your refund's id, or the payment's id plus a suffix) and point at the payment with `original_transaction_id`. One call per refund, partial refunds included: ```php $ripples->revenue(-29.99, 'user_123', [ 'transaction_id' => 'txn_abc123:refund:1', 'original_transaction_id' => 'txn_abc123', ]); ``` A refund sent under the payment's own `transaction_id` is still recorded, keyed by the payment id and the refund's time, so a retry of the same call is not counted twice. Its own id is still the safer contract: two refunds sent in the same second under the payment's id would collapse into one. Pass a `DateTimeInterface` as the fourth argument to record a payment or refund when the money actually moved, e.g. when importing history: ```php $ripples->revenue(99.00, 'user_123', ['transaction_id' => 'invoice:812'], new DateTimeImmutable('2021-03-01 10:00')); ``` Revenue for a user Ripples has not seen yet is kept on that user: Ripples creates the person under your `user_id`, and their later `identify()` or `signup()` (from your server or the browser) lands on the same person and still records their signup. So `identify()` and `revenue()` for a new user can go in the same flush, in either order. Pass `signed_up_at` to that `identify()` so the signup is dated correctly. ### 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); ``` When importing history, pass the real dates. Without `canceled_at` a cancel is dated the day Ripples receives it, which puts every historical churn on the import day: ```php $ripples->subscription('sub_123', 'user_456', 'canceled', 29.00, 'month', [ 'started_at' => new DateTimeImmutable('2021-01-05'), 'canceled_at' => new DateTimeImmutable('2023-05-10'), ]); ``` Trials (`trialing`) are not counted in MRR: they show beside it until the subscription turns `active`. `past_due` still counts, since the payment is owed. ### Auto-renew off is not a cancel `canceled` means the subscription **has ended**: nothing grants access and nothing will bill. Ripples drops it from MRR and dates the churn at `canceled_at`. A subscriber who switched auto-renew off has not churned yet: they are paid up, or still in their trial, until the period ends. Report that as the live status plus `renews => false` and the date the period ends: ```php // Free trial started: report the price it will bill at, not the trial's 0 $ripples->subscription('sub_123', 'user_456', 'trialing', 9.99, 'month', [ 'renews' => true, 'period_ends_at' => new DateTimeImmutable('2026-10-14T08:10:00Z'), ]); // They turned auto-renew off during the trial: still trialing, won't convert $ripples->subscription('sub_123', 'user_456', 'trialing', 9.99, 'month', [ 'renews' => false, 'period_ends_at' => new DateTimeImmutable('2026-10-14T08:10:00Z'), ]); // The period ran out: now it is canceled, dated when access ended $ripples->subscription('sub_123', 'user_456', 'canceled', 9.99, 'month', [ 'canceled_at' => new DateTimeImmutable('2026-10-14T08:10:00Z'), ]); ``` A paid subscription with `renews => false` stays in MRR until it ends and is also counted as `ending_mrr`. A trial with `renews => false` is left out of `trial_mrr`, since it will not convert. If you never send the final `canceled`, Ripples ends a non-renewing subscription itself at `period_ends_at`, which covers a webhook that never arrived. Leaving `renews` out keeps the last value Ripples was told; a subscription that never reported it is treated as renewing. Reporting state is idempotent: send the whole current state on every store notification and every reconcile, not only on changes. | Store says | `status` | `renews` | `period_ends_at` | `canceled_at` | | --- | --- | --- | --- | --- | | Apple status 1, current transaction is a free-trial offer | `trialing` | `autoRenewStatus == 1` | `expiresDate` | | | Apple status 1 | `active` | `autoRenewStatus == 1` | `expiresDate` | | | Apple status 3 / 4 (billing retry, grace) | `past_due` | `autoRenewStatus == 1` | grace end, else `expiresDate` | | | Apple status 2 (expired) | `canceled` | | | `expiresDate` | | Apple status 5 (revoked) / a refund | `canceled` | | | `revocationDate` | | Play `SUBSCRIPTION_STATE_ACTIVE` | `active` (`trialing` in a free-trial phase) | `true` | `expiryTime` | | | Play `SUBSCRIPTION_STATE_CANCELED`, expiry in the future | `active` | `false` | `expiryTime` | | | Play `IN_GRACE_PERIOD` / `ON_HOLD` | `past_due` | `true` | `expiryTime` | | | Play `PAUSED` | `paused` | | | | | Play `EXPIRED`, or `CANCELED` past its expiry | `canceled` | | | `expiryTime` | | Stripe `cancel_at_period_end: true` | the subscription's status | `false` | `current_period_end` | | `amount` is what the **next** billing cycle charges: Apple's `renewalPrice`, not the trial transaction's 0, so that `trial_mrr` and the MRR a trial converts into are both right. Apple has no `renewalPrice` once auto-renew is off, since there is no next period. For a trial that will not convert, or a subscription that has ended, send `0`: no number reads that amount, and Ripples keeps the plan it already has on a cancel. Do not skip the call, because the state is what changed. ### 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), `started_at` and `canceled_at` (`DateTimeInterface` or ISO 8601 string) for history imports, `renews` (`bool`: will it bill again when the period ends) and `period_ends_at` (when the current paid or trial period ends) — see [Auto-renew off is not a cancel](#subscription-renewal). | ## 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 milestones A milestone is a moment in a person's lifecycle that you count **once per person**, date by when they first reached it, and attribute to how they were acquired: `mql`, `sql`, `demo_requested`, `demo_held`, `email_verified`, `onboarding_completed`. It is not product usage, so it never feeds activation or retention. See [Track Milestones](/docs/milestones) for the rule of thumb and the recommended names. ```php // A known user, dated by when it happened (the CRM's stage-change time) $ripples->milestone('mql', userId: (string) $user->id, properties: ['source' => 'hubspot'], timestamp: $changedAt); // A lead with no account yet: the tracker cookie (read automatically under PHP-FPM) or a verified email $ripples->milestone('demo_requested', email: $form->email, properties: ['company_size' => $form->size]); ``` ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `name` (required) | `string` | snake_case (`^[a-z][a-z0-9_]{0,63}$`). Built-in moments (`signed_up`, `activated`, `first_paid`…) and billing events (`payment_failed`, refunds, renewals) are refused. | | `userId` (optional) | `?string` | Your user. Strongest identity. | | `properties` (optional) | `array` | Any context worth keeping. Every property is stored, including keys named like identity fields (`email`, `plan`). | | `timestamp` (optional) | `?DateTimeInterface` | When it happened. Omit for "now". | | `email` (optional) | `?string` | For a lead with no account. Merged into the account when you later `identify()` the user with the same email. Send it only once verified. | | `visitorId` (optional) | `?string` | A browser visitor id, when you keep it somewhere other than the cookie. Beats `setVisitorId()` and the cookie. | At least one of `userId`, a visitor (cookie, `setVisitorId()` or `visitorId`) or `email` is required; a milestone naming nobody is not sent and is passed to `on_error`. In a request that is not the person's own (an admin marking a user as SQL), the cookie belongs to the admin: send it from a queue job, where no cookie is read. ## 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([...]); // Same request as the form submit: the SDK reads the tracker's // _rpl_vid cookie by itself, so the signup keeps its traffic source. $ripples = new Ripples(); $ripples->signup($user->id, [ 'email' => $user->email, 'name' => $user->name, ]); return $user; } } ``` > Keep the visitor ID with the signup. On a web request the SDK picks up the _rpl_vid cookie automatically. If the account is created later (email confirmation link, queue job), that cookie is missing or belongs to a different browser: store $_COOKIE['_rpl_vid'] at form time and pass it as 'visitor_id', or call setVisitorId() in the worker. Without it the signup is attributed to Direct. See Preserving 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', 'signed_up_at' => $user->created_at->toIso8601String(), 'company' => 'Acme Inc', // custom property 'role' => 'admin', // custom property ]); ``` > Always pass signed_up_at from server-side calls. This method can be the FIRST time Ripples ever hears about a user — e.g. a nightly job, a login listener, or any user who never went through a client-side signup. When that happens, Ripples treats the call as a brand-new signup and, with no signed_up_at, dates it "now" instead of the user's real account age. This corrupts your signups chart and retention cohorts. There's no downside to always sending it: it only ever moves the signup date earlier, never later, so it's safe to include on every call. ### 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`. | | `signed_up_at` (optional), but always recommended server-side | `string` | ISO 8601 date string of when the user actually signed up (e.g. `$user->created_at`). If this is the first `identify()` call Ripples has ever seen for this user, it's synthesized into a Signup event — omit this and it gets dated "now" instead of the real signup date. | | `visitor_id` (optional) | `string` | The browser visitor UUID from the `_rpl_vid` cookie. Accepted by `signup()` and `identify()`. Links the user to the anonymous session that brought them in, so they inherit its channel, source and campaign. Read automatically from `$_COOKIE` on web requests; pass it explicitly (or via `setVisitorId()`) from confirmation handlers and queue workers. See [Preserving attribution](/docs/attribution). | | `[custom]` (optional) | `string | number` | Any additional traits you want to attach to the user (e.g. `plan`, `company`, `role`). | ## Track companies For B2B products: say which company a user works in with `group()`. Call it at signup, when a user joins or switches company, and when the company's traits change: ```php $ripples->group($user->id, $team->id, [ 'name' => $team->name, // shown instead of the id 'plan' => 'business', 'seats' => 25, ]); // No company needed on later calls: this counts for $team. $ripples->track('created a report', $user->id); ``` Ripples remembers who works where, so nothing has to be passed on each call. Traits merge into what the company already has. Pass `null` as the user to update a company's traits only, e.g. from a nightly sync. Use your own id for the company, never its name. See [Track Companies](/docs/companies) for how it works. ## 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` | `3` | HTTP request timeout in seconds. | | `max_queue_size` | `100` | Events are sent in one batch when the queue reaches this size, and on shutdown. Set to `1` to send every call immediately. | ### Queue workers and long-running processes The SDK sends its queue when PHP shuts down and whenever 100 events have accumulated. A queue worker (`php artisan queue:work`, Horizon, a daemon) never shuts down between jobs, so its events wait for the 100th, and are lost if the worker is killed first. Call `flush()` at the end of every job: ```php public function handle(Ripples $ripples): void { $ripples->revenue($this->payment->amount, (string) $this->payment->user_id, [ 'transaction_id' => 'payment:' . $this->payment->id, ]); $ripples->flush(); } ``` Or construct the client with `'max_queue_size' => 1` so every call is sent as it is made. ## 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` --- # Apple Ads Credit App Store installs to the Apple Ads campaign, ad group and keyword that won them, and see ROAS and payback per keyword. ## Why installs need this Ripples files every person under a channel from how they arrived: the referrer, UTM tags and ad click IDs on their first visit. An App Store install carries none of those. When your app opens its first screen (a native view or a webview running the Ripples script), that first touch is **Direct**, and every Apple Ads install is counted as Direct with it. Apple knows better. Through the AdServices framework your app can ask Apple, without the ATT prompt and without the IDFA, which campaign, ad group and keyword won the install. Sending that answer to Ripples makes it the person's first touch: - they appear under the **Apple Ads** channel in Pulse, Channels, Cohorts, Revenue and Marketing; - their signups, payments and retention count toward that channel and campaign; - with the Apple Ads account connected, spend lands under the same channel, so ROAS, CAC and payback work, down to the keyword. ## 1. Send Apple's answer Apple gives the device a short-lived **attribution token** (`AAAttribution.attributionToken()`, iOS 14.3+). Exchange it for the attribution one of two ways. ### Your backend resolves the token Your app sends the token to your server; your server POSTs it to Apple and forwards Apple's JSON to Ripples. Use this if you already have a backend. It needs your **secret key**, because a resolved answer could otherwise be made up. ```bash curl -X POST https://api.ripples.sh/v1/ingest/attribution \ -H "Authorization: Bearer priv_XXXX" \ -H "Content-Type: application/json" \ -d '{ "user_id": "4812", "visitor_id": "0b8f6c1e-5d2a-4c1b-9d7e-3f0a2b4c6d8e", "resolved_at": "2026-10-02T10:04:11Z", "attribution": { "provider": "apple_ads", "attribution": true, "orgId": 40669820, "campaignId": 542370539, "adGroupId": 542317095, "keywordId": 87675432, "adId": 542317136, "countryOrRegion": "CA", "conversionType": "Download", "claimType": "Click" } }' ``` - Paste Apple's response body into `attribution` as it is, plus `"provider": "apple_ads"`. The snake_case names (`campaign_id`, `country_or_region`, …) work too. - Send `attribution: false` answers as well. They are recorded and counted; nothing else happens. - **`visitor_id`** is the Ripples visitor ID of the device, `window.ripples.getVisitorId()` in a webview once the script has loaded. **Send it whenever you have it**: it is how the install's own first screens get credited too. `user_id` is the id your `identify()` sends. Either works, both is best. - `resolved_at` is when your server got Apple's answer. It keeps a replay or a backfill from being dated today. - The response is `202 {"ok": true, "id": 812, "duplicate": false, "status": "received"}`. Sending the same answer for the same person again returns `duplicate: true` and changes nothing. ### Ripples resolves the token No backend? Send the token itself and Ripples asks Apple, retrying the way Apple recommends until the token expires (24 hours). A **publishable key** is fine here: Apple verifies the token, so it cannot be forged. From a webview with the Ripples script (v1.5.0 or later): ```javascript window.ripples.attribution({ apple_ads_token: token }) ``` Or from anywhere else: ```bash curl -X POST https://api.ripples.sh/v1/ingest/attribution \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"visitor_id": "0b8f6c1e-...", "attribution": {"provider": "apple_ads", "token": ""}}' ``` Server SDKs that queue events can send the same object through `/v1/ingest/batch` as `{"$type": "attribution", ...}`. ### Capacitor: getting the token A webview cannot call AdServices, so add a small native plugin: ```swift import Capacitor import AdServices @objc(AppleAdsAttributionPlugin) public class AppleAdsAttributionPlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "AppleAdsAttributionPlugin" public let jsName = "AppleAdsAttribution" public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "token", returnType: CAPPluginReturnPromise) ] @objc func token(_ call: CAPPluginCall) { guard #available(iOS 14.3, *) else { return call.resolve([:]) } do { call.resolve(["token": try AAAttribution.attributionToken()]) } catch { call.reject(error.localizedDescription) } } } ``` ```javascript import { registerPlugin } from '@capacitor/core' const AppleAdsAttribution = registerPlugin('AppleAdsAttribution') // Once per install, once the Ripples script (v1.5.0+) has loaded: the // loading stub has no attribution(). localStorage is wiped when the app is // deleted, so a reinstall sends again. if (!localStorage.getItem('rpl_asa_sent') && typeof window.ripples?.attribution === 'function') { const { token } = await AppleAdsAttribution.token() if (token) { window.ripples.attribution({ apple_ads_token: token }) localStorage.setItem('rpl_asa_sent', '1') } } ``` Mark the install sent only after the loaded script has taken the token. The page never hears what Ripples did with it, so a flag written any earlier can hide an install that was never sent. If the script is not there yet, try again next launch with a **fresh** token rather than an old one: tokens expire after 24 hours. ## How the install becomes the first touch | The person's current first touch | What happens | |---|---| | None yet, or Direct | **Apple Ads becomes the first touch** | | A real source (search, social, an ad, a referral, email) that came **earlier** | Kept. Apple also claims the install; the inspector shows both | | First seen more than 30 days before the install (90 for pre-orders) | Kept: a returning user reinstalling, not an acquisition | | Already credited to an earlier Apple Ads install | Kept: the reinstall still counts in Apple's own numbers | Once applied, a later Direct visit on any of the person's devices cannot displace it. An earlier real touch still can, as with any first touch. Apple Ads installs are filed under the stock **Apple Ads** channel. If you customised your channels, add a rule for `utm_source` matching `apple_ads` (Settings → Channels), or they land in **Paid (Other)**; the Apple Ads status tool flags the mismatch. ## 2. Connect Apple Ads for spend Settings → Integrations → Apple Ads. Ripples generates a key pair and walks you through it: 1. In Apple Ads, Account Settings → User Management → **Invite Users**. Give the API user **API Account Read Only**, or **API Account Manager** if you want your AI assistant to pause campaigns, change bids and add keywords through Ripples. 2. Signed in as that user, Account Settings → **API**: paste the public key Ripples shows, save, and copy the `clientId`, `teamId` and `keyId` back into Ripples. 3. Choose the ad account and the app. Ripples imports the last 90 days of spend (Apple's limit for daily reports) and re-syncs every night, by campaign and country for ROAS, and by ad group, keyword and search term for the detail below. The private key stays encrypted on Ripples' servers and never reaches your browser. ## Reading the results - **Channel level**: the Apple Ads row in Pulse, Channels and Cohorts, with spend, CAC, ROAS and payback like any paid channel. - **Per campaign, ad group or keyword**: ask your assistant, or call `ripples_apple_ads` with `action: "performance"` and `level: "keyword"`. Each row has spend, Apple's installs, the new people Ripples credits, CPI, CAC, D0 / D7 / D30 revenue and ROAS, and the payback day. Windows that have not closed yet are empty, never zero. - **Health**: `ripples_apple_ads` `action: "status"` shows whether spend is current, how many installs were attributed in the last week, and what share of Apple's reported installs that is. A share that drops is usually an app build that stopped sending the token. Installs won by **Search Match** have no keyword; they show as a "(no keyword)" row. Search terms carry spend but no people, because Apple attributes installs to keywords, not to what was typed. ## Managing campaigns through your assistant With **Allow campaign changes from MCP** on (Settings → Integrations → Apple Ads) and the API user on API Account Manager, your assistant can change the account as well as read it. Every change is a dry run first: Ripples reads what Apple holds, checks the change and shows what would happen, including the most it could spend. Nothing is applied until you confirm, and every change is logged on the settings page. - **Read before you change.** `ripples_apple_ads_live` shows each campaign's and ad group's dates and why Apple is not serving it (for example `SCHEDULE_PENDING` with the start date). `action: "tree"` returns a whole campaign: ad groups, targeting, keywords with bids, negatives and ads. `action: "lint"` finds structural mistakes, such as Search Match left on in an exact-match group, an ad group that starts after its campaign, or a negative that blocks one of your own keywords, each with the change that fixes it. - **Schedule and targeting.** Change a campaign's or ad group's start and end, switch Search Match, and set device, customer type, age, gender, location and hour targeting. "Pause after 48 hours" is an end time: Apple stops the campaign itself, so nothing depends on Ripples being up. - **Build.** Create an ad group, clone an ad group or a whole campaign into other countries with adjusted bids, or create a campaign. Everything is created paused, only what is missing is created, and a build that fails part way can be run again to finish. The dry run lists what will exist and what lint thinks of it before anything is created. - **Spend guard.** Ask Ripples to pause a campaign once it has spent a set amount since it started, or over the last 7 or 30 days. Apple has no lifetime budget for this. Ripples checks every 15 minutes, and because Apple reports today's spend a few hours late, the dry run tells you how far over the cap a campaign can go before it is paused. Turning changes from MCP off cancels every guard. Age or gender targeting has a cost: Apple does not attribute installs from those ad groups, so Ripples counts them as organic. The status check and lint both flag it. ## Testing With **Developer Mode** on (Settings → Privacy & Security → Developer Mode), Apple returns a fixed sample instead of a real answer. Ripples records it with the status `test` and never applies it, so your own test builds cannot credit a campaign. An install from the App Store with Developer Mode off is real data. ## FAQ **Do I need the ATT prompt?** No. AdServices works without it. When Apple leaves out the tap time, Ripples dates the install by the app's first screen. **What about SKAdNetwork / AdAttributionKit?** They report aggregates with no per-person id, so there is nothing to join to a person. AdServices is per install. **Android?** Google Play's Install Referrer has the same shape and will use the same endpoint with its own provider. --- # 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", signed_up_at=user.created_at) ``` 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. Give each refund **its own** `transaction_id` (your refund's id, or the payment's id plus a suffix) and point at the payment with `original_transaction_id`. One call per refund, partial refunds included: ```python ripples.revenue(-29.99, "user_123", transaction_id="txn_abc123:refund:1", original_transaction_id="txn_abc123", ) ``` A refund sent under the payment's own `transaction_id` is still recorded, keyed by the payment id and the refund's time, so a retry of the same call is not counted twice. Its own id is still the safer contract: two refunds sent in the same second under the payment's id would collapse into one. Pass `timestamp=` to record a payment or refund when the money actually moved, e.g. when importing history. Revenue for a user Ripples has not seen yet is kept on that user: Ripples creates the person under your `user_id`, and their later `identify()` or `signup()` (from your server or the browser) lands on the same person and still records their signup. So `identify()` and `revenue()` for a new user can go in the same flush, in either order. Pass `signed_up_at` to that `identify()` so the signup is dated correctly. ### 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) ``` When importing history, pass the real dates. Without `canceled_at` a cancel is dated the day Ripples receives it, which puts every historical churn on the import day: ```python ripples.subscription("sub_123", "user_456", "canceled", 29.00, "month", started_at=datetime(2021, 1, 5), canceled_at=datetime(2023, 5, 10)) ``` Trials (`trialing`) are not counted in MRR: they show beside it until the subscription turns `active`. `past_due` still counts, since the payment is owed. ### 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`. | | `started_at`, `canceled_at` (optional) | `datetime | str` | When the subscription started and was canceled. Pass them when importing history. | ## 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 milestones A milestone is a moment in a person's lifecycle that you count **once per person**, date by when they first reached it, and attribute to how they were acquired: `mql`, `sql`, `demo_requested`, `demo_held`, `email_verified`, `onboarding_completed`. It is not product usage, so it never feeds activation or retention. See [Track Milestones](/docs/milestones) for the rule of thumb and the recommended names. ```python # A known user, dated by when it happened (the CRM's stage-change time) ripples.milestone("mql", str(user.id), timestamp=changed_at, source="hubspot") # A lead with no account yet: the bound browser visitor or a verified email ripples.milestone("demo_requested", email=form.email, company_size=form.size) ``` ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `name` (required) | `str` | snake_case (`^[a-z][a-z0-9_]{0,63}$`). Built-in moments (`signed_up`, `activated`, `first_paid`…) and billing events (`payment_failed`, refunds, renewals) are refused. | | `user_id` (optional) | `str | None` | Your user. Strongest identity. | | `email=` (optional) | `str | None` | For a lead with no account. Merged into the account when you later `identify()` the user with the same email. Send it only once verified. | | `visitor_id=` (optional) | `str | None` | A browser visitor id. Beats the one bound with `set_visitor_id()`. | | `timestamp=` (optional) | `datetime | str | None` | When it happened. Omit for "now". | | `**properties` (optional) | `Any` | Any other keyword is a property. Every property is stored. | At least one of `user_id`, a visitor (`set_visitor_id()` or `visitor_id=`) or `email=` is required; a milestone naming nobody is not sent and is passed to `on_error`. ## 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, set_visitor_id, visitor_id_from_cookies def register(request): # Bind the browser's visitor first so the signup keeps its traffic source set_visitor_id(visitor_id_from_cookies(request.COOKIES)) user = User.objects.create_user(...) ripples = Ripples() ripples.signup(str(user.id), email=user.email, name=user.get_full_name(), ) ripples.flush() return JsonResponse({"id": user.id}) ``` ### Flask example ```python from ripples import Ripples, set_visitor_id, visitor_id_from_cookies @app.route("/register", methods=["POST"]) def register(): set_visitor_id(visitor_id_from_cookies(request.cookies)) user = create_user(...) ripples = Ripples() ripples.signup(str(user.id), email=user.email, name=user.name, ) ripples.flush() return jsonify({"id": user.id}) ``` > Keep the visitor ID with the signup. The Python SDK does not read cookies on its own: call set_visitor_id(visitor_id_from_cookies(...)) in the request that handled the signup form, or pass visitor_id= on the call. If the account is created later (email confirmation link, Celery task), store the ID at form time and pass that one; the cookie in the confirming browser is a different visitor. Without it the signup is attributed to Direct. See Preserving 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", signed_up_at=user.created_at, company="Acme Inc", # custom property role="admin", # custom property ) ``` > Always pass signed_up_at from server-side calls. This method can be the FIRST time Ripples ever hears about a user — e.g. a nightly job, a login handler, or any user who never went through a client-side signup. When that happens, Ripples treats the call as a brand-new signup and, with no signed_up_at, dates it "now" instead of the user's real account age. This corrupts your signups chart and retention cohorts. There's no downside to always sending it: it only ever moves the signup date earlier, never later, so it's safe to include on every call. ### 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`. | | `signed_up_at` (optional), but always recommended server-side | `str | datetime` | ISO 8601 date (or datetime) of when the user actually signed up (e.g. `user.created_at`). If this is the first `identify()` call Ripples has ever seen for this user, it's synthesized into a Signup event — omit this and it gets dated "now" instead of the real signup date. | | `visitor_id` (optional) | `str` | The browser visitor UUID from the `_rpl_vid` cookie. Accepted by `signup()` and `identify()`. Links the user to the anonymous session that brought them in, so they inherit its channel, source and campaign. Bind it per request with `set_visitor_id(visitor_id_from_cookies(request.COOKIES))`, or pass it explicitly from confirmation handlers and background tasks. See [Preserving attribution](/docs/attribution). | | `[custom]` (optional) | `str | int | float` | Any additional traits you want to attach to the user (e.g. `plan`, `company`, `role`). | ## Track companies For B2B products: say which company a user works in with `group()`. Call it at signup, when a user joins or switches company, and when the company's traits change: ```python ripples.group(user.id, team.id, name=team.name, # shown instead of the id plan="business", seats=25, ) # No company needed on later calls: this counts for team. ripples.track("created a report", user.id) ``` Ripples remembers who works where, so nothing has to be passed on each call. Traits merge into what the company already has. Pass `None` as the user to update a company's traits only, e.g. from a nightly sync. Use your own id for the company, never its name. See [Track Companies](/docs/companies) for how it works. ## 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. > Queue workers. A Celery, RQ or Dramatiq worker never exits between tasks, so its events wait until 100 have queued up, and are lost if the worker is killed first. Call flush() at the end of every task, or create the client with max_queue_size=1 to send each call as it is made. ## 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/RipplesMetrics/ripples-ios", from: "0.1.6") ``` Or in Xcode: **File → Add Package Dependencies** and paste `https://github.com/RipplesMetrics/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", "signed_up_at": user.createdAt.iso8601String, ]) ``` > Pass signed_up_at when you know it. If this is the first identify call Ripples has ever seen for this user — e.g. they signed up on the web and this is their first app launch days later — it's synthesized into a Signup event. Without signed_up_at it's dated to the moment of this call instead of the user's real signup date, which skews your signups chart and retention cohorts. ### Parameters | Parameter | Type | Description | | --- | --- | --- | | `userId` (required) | `String` | Your internal user ID. | | `traits` (optional) | `[String: Any]` | User traits: `email`, `name`, `avatar_url`, `signed_up_at` (ISO 8601 — see note above), 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 milestones Available from 0.1.17. A milestone is a moment in a person's lifecycle that you count **once per person**, date by when they first reached it, and attribute to how they were acquired. It is not product usage, so it never feeds activation or retention. See [Track Milestones](/docs/milestones) for the rule of thumb. ```swift Ripples.shared.milestone("onboarding_completed", properties: ["steps": 4]) Ripples.shared.milestone("paywall_viewed", properties: ["placement": "settings"]) Ripples.shared.milestone("notifications_enabled") ``` It belongs to this device's person: the identified user, or the anonymous visitor before `identify()`, whose milestones move with them when they sign up. Names are snake_case (`^[a-z][a-z0-9_]{0,63}$`); an invalid one is logged and dropped. Recommended for apps: `onboarding_completed`, `paywall_viewed`, `checkout_started`, `notifications_enabled`, `tracking_authorized`, `integration_connected`, `invite_sent`, `invite_accepted`, `email_verified`. Sales stages (`mql`, `sql`, `demo_held`) come from your server: [PHP](/docs/php-sdk#milestones), [Python](/docs/python-sdk#milestones) or the [batch API](/docs/milestones#http). ## 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]) ``` ## Track companies For B2B apps: say which company the user works in. Call `group` once you know it, typically right after `identify`: ```swift Ripples.shared.identify(user.id, traits: ["email": user.email]) Ripples.shared.group(team.id, traits: [ "name": team.name, // shown instead of the id "plan": "business", ]) ``` Every event sent afterwards counts for that company, across launches, until you call `group` with another id (the user switched company) or `resetGroup()`: ```swift Ripples.shared.resetGroup() // e.g. on logout ``` Use your own id for the company, never its name. Traits merge into what Ripples already has for it. See [Track Companies](/docs/companies) for how it works. ## 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+ --- # Traffic quality and fraud detection How Ripples scores every visit, which visitors it removes from your numbers, and which it never removes. Ripples checks every pageview for signs of automation and gives each visitor one verdict. Crawlers and proven automation are always removed. Traffic that is only suspicious stays counted until you switch on a filter. Signed-in users, paid clicks and conversion events are never removed. ## How it works Each pageview goes through the same steps on the server as it arrives: 1. Protected visits are set aside and always counted: signed-in users, paid ad clicks, devices you marked as real, and every event that isn't a pageview. Signed-in users and paid clicks are still scored so you can see their quality, but the score can't remove them. 2. If the user agent names a robot (Googlebot, GPTBot, AhrefsBot, uptime monitors, link unfurlers), the visit is a Crawler. 3. Everything else is checked against about two dozen signals. Each signal that fires adds points to a [bot score](#bot-score) on a 0 to 100 scale. 4. The score, and the kind of signal behind it, give the visitor a verdict: Looks human (under 20), Unusual (20 to 44), Suspect (45 to 59) or Automated (60 or more). 5. Crawler and Automated leave your numbers. Suspect visitors and datacenter connections leave only if you switch those filters on. 6. On Pulse, the Traffic quality section adds up the verdicts per campaign, source and channel and tells you whether that traffic looks real. ## The bot score Every pageview gets one number: the bot score, on a 0 to 100 scale. 0 means nothing was found against the browser. Each sign of automation adds points, and the total decides the verdict: | Bot score | Verdict | |---|---| | 0 | Nothing found | | 1 to 19 | Looks human | | 20 to 44 | Unusual | | 45 to 59 | Suspect | | 60 or more | Automated, or Suspect if the visit looks like a person arriving | A visitor's score is their worst pageview over the period, not an average. The same number appears everywhere the product talks about credibility: the Credibility block on a person's page, the tooltip behind a mark in People, the "Bot score" table in the Traffic quality section of Pulse, and `ripples_person` over MCP. Where you see "peak" it means this: the highest score any of that visitor's pageviews reached. The score is a plain sum, so a pageview that stacks several proof signals can go past 100. Nothing changes above 60, so treat anything higher as "Automated, and then some". ## How the score is built There are two kinds of signal. Proof signals are contradictions that no real browser produces, such as a browser that claims to be Firefox while running Chrome's engine. Each is worth 60 to 70 points. Only proof signals can make a visitor Automated. Hints describe a setup that is unusual but can belong to a person: a VPN, a datacenter address, a clock that disagrees with the IP location. Each is worth 5 to 25 points. However many hints fire, they can't take a visitor past Suspect. | Proof signal | Points | |---|---| | Chromium engine under a Firefox user agent | 70 | | Reported browser contradicts the request headers | 70 | | Chromium runtime with a blanked user agent | 70 | | Browser automation framework detected | 70 | | Chromium-only APIs under a Firefox user agent | 65 | | Claims Firefox but is missing Firefox internals | 60 | | Remote-controlled browser (WebDriver) | 60 | | Hint | Points | |---|---| | One device presenting as many visitors (see [fleets](#fleets)) | 25 | | Connecting from a datacenter | 20 | | Device clock doesn't match the IP location | 20 | | No real graphics hardware | 20 | | Window has no browser interface (desktop) | 15 | | Default automation window size, 1280 × 720 | 15 | | No mouse, scroll or keyboard activity in the first seconds | 15 | | Connecting through a VPN, proxy or Tor | 15 | | No desktop or taskbar around the window (desktop) | 10 | | No browser plugins (desktop) | 10 | | Language headers are inconsistent | 10 | | Missing standard browser request headers | 10 | | Mouse movement looks synthesized | 10 | | Only one language configured | 5 | | Two CPU cores or fewer (desktop) | 5 | A desktop with no browser interface and no taskbar gets 20 extra points on top of those two hints, because together they describe a headless browser in a virtual screen. That puts it at 45, which is Suspect. Three rules sit on top of the points: - The score is a plain sum, and a visitor is judged by their worst pageview, not an average. - A visit that looks like a person arriving is capped at Suspect. If it has a referrer or UTM tags, is the second page of a session, or moved the mouse, even a proof signal makes it Suspect instead of Automated. A referrer takes one line of code to fake, so the visit is still flagged. - The tracker reports raw facts about the browser and never scores itself. Weights and thresholds live on the server, and the server overwrites the verdict fields on every event, so a client can't send in its own verdict. ### Residential proxy fleets A scraper that rents residential proxies can show thousands of IP addresses in dozens of countries, but it usually runs one browser build. Ripples hashes what a proxy can't change: engine and version, OS, screen and window size, language, timezone and the browser's reported environment. A build is flagged as a fleet when, within two hours, all of these hold: - it appears as 50 or more visitors, - from 5 or more countries, - almost none of them (under 1%) continue to a second page, - and 90% or more have a clock that disagrees with their IP location. Every visitor on that build then gets the "One device presenting as many visitors" hint for the next 24 hours. Most of them also carry the clock mismatch, which brings them to 45 points: Suspect. ### Networks Whether an address is a datacenter comes from public registry data about which organisation owns it. VPN, proxy and Tor exits come from public range lists. Ripples buys no reputation scores and keeps no list of "bad" IPs. iCloud Private Relay and Cloudflare WARP are never flagged as either. ## Verdicts | Verdict | How a visitor gets it | Counted? | |---|---|---| | Looks human | Score under 20 | Yes | | Unusual | 20 to 44, from hints only. Travellers on a corporate VPN, virtual desktops, privacy browsers. Mostly people. | Yes, and it can't be filtered | | Suspect | 45 or more from hints, or a proof signal on a capped visit | Yes, unless the Suspect filter is on | | Automated | A proof signal on a visit that isn't capped, so always 60 or more | No, always removed | | Crawler | The user agent names a robot | No, always removed | Unusual can't be filtered because removing it would remove real people. Anything that rests on statistics alone stops at Suspect and stays counted until you choose otherwise. ## Who is never removed These visits are never removed by any filter, including the ones that are always on: | Visit | Why | |---|---| | Signed-in users | Once someone identifies, their later visits stay, whatever their network looks like. Scrapers don't log in. | | Paid clicks | Any visit carrying `gclid`, `gbraid`, `wbraid`, `dclid`, `gad_source`, `fbclid`, `ttclid`, `msclkid`, `twclid`, `rdt_cid`, `li_fat_id`, `mc_cid` or `irclid`. Your payback numbers include every click you paid for. | | Signups, custom events, revenue | Only pageviews are judged. `identify`, `track` and revenue events are never classified, so those counts are exactly what your app sent. | | Server SDK events | Never classified. | | Devices you marked as real | See [the FAQ](#faq-marked-real). | Signed-in users and paid clicks are still scored in the background. Their findings appear on the person's page and in the Traffic quality section, so a campaign full of automated clicks shows up as bad quality instead of disappearing from your reports. ## Marks in People Most visitors carry no mark. When one does, hover it to see the findings in plain words, for example "Device clock doesn't match the IP location" or "Remote-controlled browser". | Mark | Meaning | Counted? | |---|---|---| | Looks automated | Suspect, and at least one finding describes a scripted or headless browser | Yes, unless the Suspect filter is on | | Suspect | Suspect from network, clock and similar hints, which a traveller on a corporate network can also produce | Yes, unless the Suspect filter is on | | Datacenter connection | Connected from a hosting or cloud network. Nothing else looked automated. | Yes, unless the Datacenter filter is on | | VPN or proxy | Connected through a VPN, proxy or Tor exit. Nothing else looked automated. | Yes, always | A signed-in user never shows the first two marks. The network marks still show, because they're facts about the connection.
A person page for an anonymous visitor marked Looks automated, on Chrome on a Linux desktop. The session lists eight pageviews, most with a robot icon, and a session score of 75. One pageview is open on the right: its Credibility block shows score 55, verdict Suspect, and two findings: No browser window and no desktop around it, and No browser plugins.
A person's page. Click any pageview to see its score, its verdict and the findings behind them. Here a headless window with no plugins adds up to 55 points: Suspect, and marked Looks automated because the tells describe a machine.
## Filters Filters are in Settings, Traffic filtering. Each switch shows its cost: how many visitors it would have removed in the last 30 days.
Settings, Traffic filtering. Known crawlers and Automated browsers are locked on and removed 38 and 13 visitors. Suspect visitors and Datacenter connections are off and would remove 121 visitors (2.7%) and 249 visitors (5.5%). A note says impact is measured over the last 30 days as a share of 4,522 judged visitors, and that one visitor can match both filters.
The two optional filters show what they would remove before you switch them on.
| Filter | Removes | Also catches | Default | |---|---|---|---| | Known crawlers | Robots that name themselves | An internal monitor, or a corporate proxy that rewrites the user agent to look like a robot | Always on | | Automated browsers | The Automated verdict | A first-party desktop app with a fully custom user agent | Always on | | Suspect visitors | The Suspect verdict | Travellers on corporate networks, cloud desktops, kiosks, privacy browsers. About 5% of a normal audience. | Off | | Datacenter connections | Addresses owned by a hosting or cloud network | Cloud security proxies (Zscaler, Netskope), cloud desktops, VPNs that exit inside AWS or GCP | Off | How filters behave: - A filter applies to new traffic from the moment you save it. Past data isn't rewritten in either direction. - Protected visits are never removed, whatever the switch says. - A removed visit isn't deleted. It stays in Recently removed for 30 days with its verdict and findings, so you can check the classifier's work.
The Recently removed list with one Automated row opened. Its findings are Remote-controlled browser (WebDriver), No browser window and no desktop around it, Window has no browser interface, No desktop or taskbar, No browser plugins, and Missing standard browser request headers. The details show the network, a Chrome 99 on Windows user agent, a 1600 by 1600 screen, no referrer and a score of 125, above a This is a real visitor button.
Recently removed, with one visit opened. Every removal shows its findings, network, device and score, and the button that marks the device as real. IP addresses in this screenshot are replaced with documentation addresses.
## The Traffic quality section The section sits at the bottom of Pulse. For the whole project it shows: - one read, with the share of visitors removed and the share counted but flagged; - the period's visitors in bands: Looks human, Unusual, Suspect, Automated and Crawlers. Outlined bands are counted, filled bands were removed; - with a campaign, source or channel selected, a Bot score table: the slice's visitors by their worst pageview's score (0, 1 to 19, 20 to 44, 45 to 59, 60 or more) next to everyone else. "Automated, kept" is the 60-or-more row: browsers that stayed counted because they arrived from a link or a campaign, on a paid click, or signed in; - a Quality by table that lists your campaigns, sources or channels, each with its own read; - Findings in counted traffic: which findings fired most among the visitors who stayed in your numbers.
The Traffic quality section for the last 30 days, 4,353 visitors. The read says Your visitors look like real people: 1.1% removed, 12% counted but flagged. A band bar shows 86.4% Looks human, 9.8% Unusual, 2.6% Suspect, 0.3% Automated and 0.9% Crawlers. Below it, a Quality by Source table: Google Search reads Clean, Direct and a partner site read Mixed, and Ad network A reads Low with 62% bounced and 0% signed up. Sources under 50 visitors read Too early.
Quality by source on a real project, with names changed. Search traffic reads Clean. Ad network A reads Low: its visitors pass the bot checks, but 62% bounce and none sign up.
Click a row, or filter Pulse to a campaign, source or channel, and the section compares that slice with everyone else. You get the read with up to three supporting facts, the slice's peak scores next to the rest of your traffic, and a table comparing bounce, pages per visit, signups, activation, payments and network mix.
Traffic quality filtered to Ad network A, 980 visitors in 30 days. The read says Most of these visitors are not worth paying for, with the facts 62% bounced after one page (everyone else: 22%) and 0 of 980 signed up (everyone else: 20%). 95.5% of the slice Looks human. The peak-score table shows the slice close to everyone else. The comparison table shows 1.5 pages per visit against 5.7, and 0% signed up and 0% paid against 19.6% and 2.4%.
The same ad network, clicked. 95% of its visitors look human to the classifier, so nothing is removed. The comparison with the rest of the project is what gives it away: one and a half pages per visit, and 0 signups out of 980.
| Read | What it says | Triggered by any of | |---|---|---| | Clean | "Your visitors look like real people." With a filter on: "These visitors behave like the rest of your traffic." | None of the triggers below | | Mixed | "These visitors are mixed. Some of them are real." | 5% or more suspect or removed; bounce rate 10+ points above the rest; 15% or more Unusual | | Low | "Most of these visitors are not worth paying for." | 15% or more suspect or removed; 20% or more with a peak score of 45+; 20 or more visitors per device build (at 100+ visitors); bounce 25+ points above the rest with half the signup rate or less; 30% or more from datacenters, VPNs or mismatched clocks, with 25% or more Unusual | | Junk | "These visitors look automated or bought." | 40% or more suspect or removed; 40% or more with a peak score of 45+; bounce 30+ points above the rest with zero signups from 200+ visitors, coming mostly from datacenters, VPNs or a few device builds | A slice or a table row with fewer than 50 measured visitors gets no read. It shows "Too early" instead. > Filters decide what is in your numbers. The Traffic quality section only describes it and has no controls. Switching a filter moves visitors between bands, so it can't make a campaign look better. ## Where to find it | Place | What you get | |---|---| | Pulse | The Traffic quality section, per period and per filter | | People | The mark beside a name, with findings on hover | | A person's page | Verdict, bot score and findings, for the person and for each pageview | | Settings, Traffic filtering | The filters with their 30-day cost, and Recently removed | | MCP | `ripples_overview` carries the quality payload, `ripples_person` the per-pageview verdict, score and findings, and `ripples_configure` sets the filters | ## What Ripples doesn't do - Block traffic. Ripples observes visits. It doesn't sit in front of your site and can't refuse a request. - Dispute clicks or claim refunds. It gives you the evidence for that conversation with an ad network. - Use third-party reputation scores, or canvas or audio fingerprinting. - Remove people for privacy choices. VPN, proxy and Tor are findings you can see, never a filter. - Catch paid humans. People paid to click are real people to a classifier. The bounce and signup comparison is how you spot them: they leave after one page and never sign up. ## FAQ ### My ad campaign looks like bots. Why are those clicks still counted? Paid clicks are never removed, so your spend and payback maths covers every click you paid for. They are still scored. Filter Pulse to the campaign: a Low or Junk read, with its supporting facts, tells you what you bought. ### How do I show an ad network that its traffic was fake? Filter Pulse to the campaign and take the read and its facts: share suspect or removed, bounce and signup gap against the rest, visitors per device build. Then open a few of that campaign's visitors in People. Each person's page lists the findings for every visit, in plain words. ### Will people on a VPN or a corporate network be removed? Not by default. A VPN, a datacenter address and a mismatched clock are all hints. Together they usually land in Unusual, sometimes Suspect, and both are counted. They're removed only if you switch on the Suspect or Datacenter filter. ### Can bots inflate my signups or revenue? Ripples never removes `identify`, `track` or revenue events, so those counts are exactly what your app sent. If a bot submits your signup form, that signup is counted, because Ripples only judges pageviews. The Traffic quality read will still show a campaign whose visitors look automated. ### I switched on the Suspect filter. Why didn't last month change? Filters apply to new traffic from the moment you save them. Past data isn't rewritten in either direction. ### A colleague or our own uptime monitor keeps getting removed. How do I fix it? Go to Settings, Traffic filtering, Recently removed. Find the visit and choose "This is a real visitor". That device build is counted from then on, including for the always-on filters. ### Why is there no score slider or "count everything" switch? Turning the always-on filters off would only add back crawlers and proven automation, which nobody wants counted. A slider means tuning a model you can't see. If Ripples is wrong about a specific device, mark it as real. ### Are Googlebot and AI crawlers counted anywhere? They're removed from your visitor numbers and shown in the Crawler band of the Traffic quality section, so you can see how much crawling your site gets. ### Can I see the raw score for a visitor? Yes. The Credibility block on a person's page shows their [bot score](#bot-score) (the worst pageview) and the findings behind it, and each pageview in the session timeline shows its own. `ripples_person` returns the same over MCP. --- # Delete user data (GDPR, CCPA) Erase a person from Ripples on a GDPR or CCPA request with one API call, and reset or opt out the browser with the JS SDK. Under GDPR you are the controller of the data you collect about your users, and Ripples processes it for you. When one of them asks to be forgotten, you answer the request. The API below is how you remove everything Ripples holds about them, from the same backend that deletes their account. ## What gets deleted One request erases the whole person, not just the id you sent. Ripples follows its identity graph first: every device the person used, every anonymous session that was later linked to their account, and the events your server sent for them. | Data | When it is gone | |---|---| | The person's profile: name, email, avatar, plan, custom traits, first touch, location, devices | About a minute | | Their subscriptions (MRR records) and app install attributions | About a minute | | Their events: pageviews, product usage, signups, payments, identifies | The nightly run, within 24 hours | | Their sessions, Web Vitals samples and filtered bot hits | The nightly run, within 24 hours | | Them in daily totals (visitors, signups, payers) | Rebuilt from the cleaned events after the nightly run | What stays: - **Totals with nobody in them.** Saved MRR history and traffic-quality counts are numbers per day with no identifier inside, so they are not personal data and are kept. - **The deletion request itself.** The ids you sent and the dates the request was carried out are kept as your record that you handled it. > Past reports change. A deleted person's visits, signup and payments leave every report, so earlier days can show a little less traffic, fewer signups or less revenue than before. That is the point of an erasure. ## Delete a person Send the ids you know for the person. Use your **secret key** (Settings → Integrations → Project secret key). A publishable project token is refused, because it sits in every page of your site. ```bash curl -X POST https://api.ripples.sh/v1/privacy/deletions \ -H "Authorization: Bearer priv_XXXX" \ -H "Content-Type: application/json" \ -d '{ "user_ids": ["4812"], "emails": ["jane@example.com"], "requester": "privacy@yourcompany.com" }' ``` | Parameter | Type | Description | | --- | --- | --- | | `user_ids` (optional) | `string[]` | The ids you pass to `identify()` and `signup()`. The best handle: it reaches every device the person signed in on. | | `visitor_ids` (optional) | `string[]` | Device ids (UUIDs) from `ripples.getVisitorId()`. Use these for someone who never signed in. If the device belongs to a signed-in person, that whole person is deleted. | | `emails` (optional) | `string[]` | Matched against profile emails, ignoring case. Useful when the request names only an address, for example a Stripe customer who never signed in. | | `requester` (optional) | `string` | Free text for your own records, such as who asked or a ticket number. | Send at least one id and at most **100 ids in total** per request. To delete more people, send more requests. The response is `202` with the request: ```json { "id": "9d2c6f0e-8a41-4f7b-b1f2-6c3e5a7d9b10", "status": "pending", "user_ids": ["4812"], "visitor_ids": [], "emails": ["jane@example.com"], "requester": "privacy@yourcompany.com", "profiles_deleted": null, "created_at": "2026-10-01T14:03:11Z", "profiles_deleted_at": null, "completed_at": null } ``` Keep the `id` if you want to show later that the request was completed. ### Shared devices A visitor id reaches two things: the person who last signed in on that device, and the device's anonymous activity (everything recorded while nobody was signed in). Activity recorded under a **different** user id is never deleted through it. Two people who share a laptop, or someone who deletes their account and signs up again on the same phone, keep their own data. ## Check progress ```bash curl https://api.ripples.sh/v1/privacy/deletions/9d2c6f0e-8a41-4f7b-b1f2-6c3e5a7d9b10 \ -H "Authorization: Bearer priv_XXXX" ``` | `status` | Meaning | |---|---| | `pending` | Accepted. The profile is about to be erased. | | `processing` | Profile, subscriptions and caches are erased (`profiles_deleted` says how many profile records). Events are waiting for the nightly run. | | `completed` | Everything listed in [What gets deleted](#what) is gone. `completed_at` is the time. | To list recent requests, newest first: ```bash curl "https://api.ripples.sh/v1/privacy/deletions?status=processing&limit=50" \ -H "Authorization: Bearer priv_XXXX" ``` This returns `{"data": [ ... ]}` with the same objects. `limit` defaults to 50, maximum 100. ## In the browser The API erases what Ripples stored. The tracking script also keeps a device id in the browser, in the `_rpl_vid` cookie and `localStorage`. Three calls control it (tracker v1.6.0 and later). ### On logout: ripples.reset() ```javascript ripples.reset() ``` Forgets the signed-in user and starts the browser over as a new anonymous visitor with a new session. Call it when someone logs out, so whoever uses the browser next is not linked to them. Tracking carries on as normal. It is the same as `posthog.reset()` or Amplitude's `reset()`. ### When someone asks not to be tracked: ripples.optOut() ```javascript ripples.optOut() ``` Deletes every Ripples cookie and storage entry in this browser (the device id, the session, the stored user id) and stops the script sending anything, on this page and on every later visit. The only thing kept is a `_rpl_optout=1` flag, so that the choice is remembered. Call it when a user deletes their account, or from a cookie banner when consent is refused. ```javascript ripples.optIn() // undo: tracking resumes as a new visitor ripples.hasOptedOut() // true or false ``` `optIn()` counts the current page, because its load was not recorded while the browser was opted out. While opted out, `ripples.getVisitorId()` returns an empty string. > optOut() does not delete anything on the server. It only cleans the browser. To remove what Ripples already holds, call the deletion API from your backend with the user's id, and send the device id from ripples.getVisitorId() before calling optOut() if the person never signed in. ## After the deletion Deleting a person does not block them. If your app or server keeps sending events with their user id or device id, Ripples records a new person. When a user deletes their account: 1. Delete their account in your app. 2. Call the deletion API with their user id from your backend. 3. Call `ripples.optOut()` in their browser, or `ripples.reset()` if they may keep using the site anonymously. 4. Stop sending server events for that user id. Payment integrations follow the same rule. If Stripe or Paddle later sends a payment for the same customer (a refund, a final invoice), Ripples records it as a new person. Remove them from your billing provider too if the erasure covers it. ## Errors | Status | When | |---|---| | `401` | Missing or unknown key. | | `403` | A publishable project token was used. Use the `priv_` secret key. | | `404` | No deletion request with that id in this project. | | `422` | No ids, more than 100 ids, a visitor id that is not a UUID, or an invalid email. The body says which field: `{"error": "...", "errors": {...}}`. | | `429` | More than 200 requests a minute from one IP address. | ## FAQ **Can I cancel a deletion?** No. The profile is erased within about a minute, so there is nothing left to cancel. Check the ids before sending. **Does it delete the person in every project?** No. A secret key belongs to one project, and the request only reaches that project. If you track the same users in several projects, send a request with each project's key. **The person was never identified. How do I find them?** Send their device id. Read it with `ripples.getVisitorId()` in their browser, for example on your privacy request form, and send it along with the request. **Is the deletion request itself personal data?** It holds the ids you sent and nothing else. Ripples keeps it as your proof that the request was carried out.