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

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

use Ripples\Ripples;

$ripples = new Ripples();

// Track a payment
$ripples->revenue(49.99, 'user_123');

// Track a signup
$ripples->signup('user_123', ['email' => '[email protected]']);

// Track product usage
$ripples->track('created a budget', 'user_123', ['area' => 'budgets']);

// Identify / update a user
$ripples->identify('user_123', [
    'email' => '[email protected]',
    'signed_up_at' => $user->created_at->toIso8601String(),
]);

Track revenue

Call revenue() whenever a payment is processed on your server:

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

$ripples->revenue(49.99, 'user_123', [
    'email'          => '[email protected]',
    '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:

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

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

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

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

// 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 saysstatusrenewsperiod_ends_atcanceled_at
Apple status 1, current transaction is a free-trial offertrialingautoRenewStatus == 1expiresDate
Apple status 1activeautoRenewStatus == 1expiresDate
Apple status 3 / 4 (billing retry, grace)past_dueautoRenewStatus == 1grace end, else expiresDate
Apple status 2 (expired)canceledexpiresDate
Apple status 5 (revoked) / a refundcanceledrevocationDate
Play SUBSCRIPTION_STATE_ACTIVEactive (trialing in a free-trial phase)trueexpiryTime
Play SUBSCRIPTION_STATE_CANCELED, expiry in the futureactivefalseexpiryTime
Play IN_GRACE_PERIOD / ON_HOLDpast_duetrueexpiryTime
Play PAUSEDpaused
Play EXPIRED, or CANCELED past its expirycanceledexpiryTime
Stripe cancel_at_period_end: truethe subscription’s statusfalsecurrent_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.

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.

// 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 for the rule of thumb and the recommended names.

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

$ripples->signup('user_123', [
    'email'    => '[email protected]',
    'name'     => 'Jane Smith',
    'referral' => 'twitter',   // custom property
    'plan'     => 'free',       // custom property
]);

Laravel example

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

$ripples->identify('user_123', [
    'email'        => '[email protected]',
    '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.
[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:

$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 for how it works.

Error handling

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:

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

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:

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