# 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 ( {children} ) } ``` ## React (Vite / CRA) Add to your `index.html`: ```html ``` > 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: ```htmlbrowser / 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_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