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 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. |
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-curlext-json