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().

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

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 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
$ripples->milestone('mql', userId: (string) $user->id, properties: ['source' => 'hubspot'], timestamp: $changedAt);
# Python
ripples.milestone("mql", str(user.id), timestamp=changed_at, source="hubspot")
// iOS (0.1.17 and later)
Ripples.shared.milestone("notifications_enabled")

See PHP SDK, Python SDK and iOS SDK.

HTTP

POST /v1/ingest/batch with your secret key:

{
  "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:

{ "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). 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

// 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:

$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.

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_capturedWaitlist, newsletter, lead magnet, quiz-funnel email step
demo_requestedDemo or contact-sales form
demo_bookedCalendar booking
demo_heldCRM: meeting completed or logged
mqlCRM lifecycle stage: marketing qualified
sqlCRM lifecycle stage: sales qualified or opportunity
Accountemail_verifiedConfirm-email link
onboarding_completedLast onboarding step, or first core object created
integration_connectedFirst integration (calendar, mailbox, Health, App Store Connect)
teammate_invited, teammate_joinedB2B expansion
invite_sent, invite_acceptedReferral and sharing loops
Moneypaywall_viewedFirst paywall or locked page
checkout_startedPayment form opened
Mobilenotifications_enabled, tracking_authorizediOS 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:

// 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_<stage id> 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 }).