Preserving Attribution

Why a signup can lose its traffic source, and how passing the visitor ID to signup() and identify() keeps it.

The one rule: attribution belongs to the visitor ID, not to the user. Whenever your server tells Ripples about a new user, it must also say which visitor that user was. Otherwise the user is born on a blank profile and the ad, post or search that brought them in is never credited.

Where attribution lives

Every browser that loads the Ripples script gets a visitor ID, stored in the _rpl_vid cookie (root domain, 2 years) with a localStorage fallback. The first pageview from that visitor records its first touch: referrer, UTM parameters and ad click IDs (gclid, fbclid, ttclid and so on), which Ripples resolves into a channel, a source and a campaign.

That snapshot is attached to the anonymous visitor. A user only inherits it when Ripples can connect the user ID to that visitor ID. There are exactly two ways to make that connection:

  1. In the browser: ripples.identify({ id }) runs in the same browser that did the browsing. The anonymous profile adopts the user ID and keeps its first touch. Nothing else to do.
  2. From your server: signup() or identify() is called with both user_id and the browser’s visitor_id. Ripples finds the anonymous profile behind that visitor ID and links the user to it.

A server-side call that names the user but not the visitor cannot use either path. Ripples creates a profile for the user with no browsing history and no source. The anonymous visitor that saw the ad stays anonymous, and nothing that happens later can tell the two apart.

How a signup loses its source

The common case is a signup that starts in one browser and finishes in another. Take a Facebook ad:

Step What the user does What Ripples sees
1 Taps your ad. It opens in the Facebook in-app browser. Visitor A is created. First touch: Paid Social / Facebook, with the fbclid.
2 Fills in the signup form and submits it. Your backend creates the account and calls signup(user_id) without visitor_id. Ripples creates a user profile with no source. Visitor A stays anonymous.
3 Opens the confirmation email. The link opens in Safari (or Chrome), the system browser. Safari has its own cookies, so this is a brand-new visitor B. First touch: Direct (or Email, if the link carries UTMs).
4 Lands in the app, logged in. ripples.identify(user_id) merges visitor B into the user profile. The profile had no first touch, so it adopts B's: Direct. The Facebook ad is credited with nothing.

Nothing about this is specific to Facebook. Instagram, TikTok, LinkedIn, X, Gmail and Slack all open links in their own in-app browsers. Any email confirmation, magic link, “continue on desktop” flow or password manager that opens a different browser produces the same split.

The same loss happens in a single browser when the user never comes back to it: if identify() never runs where the browsing happened, the anonymous visitor is never claimed.

How to spot it in your dashboard: signups attributed to Direct whose first session starts on the confirmation or login URL, and people whose profile shows source API with zero pageviews. A large Direct share on a product that only runs paid campaigns is usually this.

Ripples repairs the most common form of this split on its own. When a signup’s first touch is Direct or Email and an anonymous session on the same device (same OS version, model, screen, language and country) ended within 15 minutes of it, that session is folded into the person and its source becomes the first touch. The match has to be unambiguous: if two anonymous sessions fit, nothing happens. People rescued this way carry a “probable” badge on their acquisition block in the visitor inspector. It covers the phone-only flow above; it cannot cover a signup that moves between devices, and it never overrides a real source. Passing the visitor ID, below, remains the reliable path.

The fix: carry the visitor ID across

Capture the visitor ID in the browser that submitted the signup form, store it with the pending signup, and pass it to signup() or identify() when you create the account, whenever and wherever that happens.

Two ways to get hold of it:

  • Same-site request (form POST or fetch to your own domain or a subdomain): the _rpl_vid cookie is sent automatically. Read it on the server.
  • Cross-origin API or native wrapper: put ripples.getVisitorId() in the request body.
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 reads _rpl_vid from $_COOKIE by itself, so a signup() call inside the request that created the account is already attributed:

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

// 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 does not read cookies on its own. Bind the visitor once per request before any call, or pass it on the call:

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

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": "[email protected]",
    "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.

Example: email confirmation flow

A Laravel app that creates the account at form submit and confirms the email afterwards:

// 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();
}
// 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.

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.