Attributing Conversions to Push Notification Clicks

Click-through tells you the notification was compelling. Attribution tells you it was worth sending. This reference covers how to carry an identifier from the send job all the way to the order row without losing it in the service worker.

Quick Answer

Mint a click id at send time, put it in notification.data, echo it from the notificationclick handler, and persist it first-party on the landing page. Do not rely on UTM parameters alone — a service-worker-initiated navigation can arrive without a referrer, and a focused existing tab never navigates at all.

Carrier Survives openWindow Survives focus() Survives redirect chain
notification.data.cid Yes (you control it) Yes (via postMessage) Yes
URL query ?cid= Yes No Only if every hop forwards it
UTM parameters Yes No Often stripped
document.referrer No — usually empty No No

The Click-Id Round Trip

Attribution is a join, and a join needs a key that exists on both sides. The key is a click id (cid): a short opaque token minted per notification per subscriber at send time, written to your send log, embedded in the encrypted payload, and echoed back by every subsequent event.

Keep the payload small. Web push ciphertext is capped at 4 KB and must use the aes128gcm content encoding defined in RFC 8291, so the payload carries ids and a URL — never a serialised product catalogue. A 16-character cid costs nothing; a rendered HTML body costs you a 413.

The click-id round trip A click id is minted at send time, embedded in notification data, read by the notificationclick handler, carried into the opened window, persisted first-party on the landing page, and finally joined against the conversion row. 1. Send job mint cid, log it, embed in notification.data.cid 2. notificationclick read event.notification.data beacon the click with cid 3. Route the user openWindow with ?cid= or focus + postMessage 4. Landing page persist cid first-party with an explicit expiry 5. Conversion order handler stamps cid onto the order row 6. Join one row per cid: send, click, order, revenue one cid identifies the same send at every hop
Every hop either carries the click id forward or the attribution chain breaks at that point.

The send side is trivial: generate the token, store it against the campaign and subscriber, and put it in both the payload data and the destination URL.

import crypto from 'node:crypto';
import webpush from 'web-push';

webpush.setVapidDetails(
  'mailto:push@example.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

export async function sendWithClickId(sub, campaign, db) {
  const cid = crypto.randomBytes(9).toString('base64url');   // 12 chars
  const url = `${campaign.landingUrl}?cid=${cid}`;
  await db.query(
    'INSERT INTO push_sends (cid, subscriber_id, campaign_id, sent_at) VALUES ($1,$2,$3,now())',
    [cid, sub.id, campaign.id]
  );
  const payload = JSON.stringify({
    title: campaign.title,
    body: campaign.body,
    data: { cid, url, campaignId: campaign.id }
  });
  // stays far under the 4 KB aes128gcm ciphertext limit
  return webpush.sendNotification(sub.subscription, payload, { TTL: 86400 });
}

Why UTM Parameters Alone Are Not Enough

UTM tags are a URL convention, and a URL only exists when a navigation happens. Three things routinely break them for push.

No referrer. A navigation started by clients.openWindow() inside a service worker has no referring document, so document.referrer is empty. Analytics libraries that infer channel from referrer classify the visit as direct — the classic symptom of push traffic showing up as an unexplained direct-traffic bump.

No navigation at all. If the handler focuses an already-open tab instead of opening a new one, the browser never loads a URL. There is no query string to read, and any analytics library waiting for a pageview sees nothing.

Redirect and app-shell loss. A single-page app that rewrites its own URL on boot, or an affiliate redirector that rebuilds the query string, will silently drop utm_* before your analytics snapshot runs. A first-party cookie written in the very first inline script is far more durable.

Keep UTM parameters — they are useful for the analytics vendor’s own channel report — but treat cid in notification.data as the source of truth. The relationship between accepted, displayed and clicked events that feeds these joins is set out in tracking push delivery vs display rates, and the click event itself is defined in measuring push notification click-through rate.

openWindow Versus Focusing an Existing Tab

notificationclick gives you a choice, and the choice has attribution consequences. Opening a new window guarantees a fresh navigation carrying your query string but abandons whatever the user had on screen. Focusing an existing client is the better user experience and the worse analytics event: nothing navigates, so nothing is measured unless you send the id across yourself.

openWindow versus focusing an existing client Opening a new window produces a navigation that carries the click id in the URL but has an empty referrer. Focusing an existing tab preserves the user's context but produces no navigation, so the click id must be delivered by postMessage. clients.openWindow(url) client.focus() fresh document load, pageview fires cid arrives in the query string document.referrer is empty discards the tab the user had open duplicate tabs on repeat clicks preserves cart, scroll and form state no navigation, so no pageview URL never carries the cid needs postMessage to hand over cid page must be listening before focus either branch must attach the click id before the tab is handed back to the user
Focus is the better experience; it just requires you to move the click id over an explicit channel.

The handler below covers both branches: it beacons the click server-side first (so attribution survives even if the page never loads), then either focuses a matching client and hands over the id by postMessage, or opens a new window.

// sw.js — attribution-safe click routing
self.addEventListener('notificationclick', (event) => {
  const d = event.notification.data || {};
  event.notification.close();
  event.waitUntil((async () => {
    await fetch('/t/click', {
      method: 'POST',
      keepalive: true,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ cid: d.cid, campaignId: d.campaignId })
    }).catch(() => {});

    const target = new URL(d.url, self.location.origin);
    const wins = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
    const same = wins.find((w) => new URL(w.url).origin === target.origin);
    if (same) {
      same.postMessage({ type: 'push-click', cid: d.cid, url: target.href });
      return same.focus();
    }
    return self.clients.openWindow(target.href);
  })());
});

On the page side, a listener stores the id first-party and lets the router decide whether to navigate. Persisting to a cookie rather than sessionStorage means the id is still available on the checkout request, which may happen in a different tab.

// app boot — capture the click id from either branch
function rememberClickId(cid) {
  if (!cid) return;
  document.cookie = `cid=${encodeURIComponent(cid)}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax; Secure`;
}
rememberClickId(new URLSearchParams(location.search).get('cid'));

navigator.serviceWorker?.addEventListener('message', (e) => {
  if (e.data?.type !== 'push-click') return;
  rememberClickId(e.data.cid);
  if (location.href !== e.data.url) history.pushState({}, '', e.data.url);
});

If clicks are landing on the wrong surface entirely, the routing logic itself is the problem rather than the attribution — see notificationclick not opening the right window.

Last-Touch and View-Through Windows

Once the id reaches the order row you still have to decide which orders push is allowed to claim. Two windows do the work.

A click-through window starts when the notification is clicked and typically runs 3–7 days for considered purchases, or as little as 24 hours for impulse categories such as the cart-abandonment playbook. Any conversion by that subscriber inside the window is credited to the click.

A view-through window starts when the notification is displayed and runs much shorter — 12 to 24 hours is a defensible choice. It exists because a notification that was seen and dismissed can still prompt a visit typed directly into the address bar. View-through credit is weaker evidence and should be reported in a separate column, never merged into the click-through number.

Click-through, view-through and last-touch windows A seven-day click-through window opens at the click six hours in and covers the conversion at thirty hours. A twenty-four hour view-through window opened at display has already closed by forty hours. An email click at twenty hours competes for last-touch credit. Which window claims one conversion at 30 h click-through, 7 d click 6 h conversion at 30 h — attributed to push view-through, 24 h display 0 h a 40 h conversion falls outside — no credit email touch email click at 20 h — wins last touch, loses first touch 0 h 24 h 48 h 72 h 120 h 168 h the green dashed line is the conversion; each window decides whether it may claim it
Report click-through and view-through credit as separate columns — collapsing them hides how much of the total is inference.

De-Duplicating Across Push and Email

If push and email both touched a subscriber before an order, both channels will claim it and your channel report will sum to more revenue than you took. Fix this once, in a single attribution query, rather than in each channel’s dashboard.

The workable rule for most teams is last non-direct touch within the shortest applicable window, with a documented tiebreaker:

  1. Collect every touch for the converting user inside the last 7 days: push clicks (by cid), email clicks (by message id), paid clicks, organic sessions.
  2. Discard touches whose own window has already expired — a view-through push display older than 24 hours is not eligible.
  3. Order the survivors by timestamp and take the latest. That channel receives 100% of the credit.
  4. If two touches land within the same 60-second bucket — common when a push and an email fire from the same lifecycle trigger — break the tie deterministically in favour of the channel the user clicked, then by channel priority, and record the tiebreaker so the report is reproducible.
  5. Write one row per order into an order_attribution table. Every downstream dashboard reads that table, never the raw touch log.
-- last non-direct touch, 7-day window, deterministic tiebreak
WITH touches AS (
  SELECT o.order_id, o.user_id, o.ordered_at, t.channel, t.touched_at, t.was_click,
         CASE t.channel WHEN 'push' THEN 1 WHEN 'email' THEN 2 ELSE 3 END AS priority
  FROM orders o
  JOIN marketing_touches t
    ON t.user_id = o.user_id
   AND t.touched_at BETWEEN o.ordered_at - INTERVAL '7 days' AND o.ordered_at
   AND (t.was_click OR t.touched_at > o.ordered_at - INTERVAL '24 hours')
), ranked AS (
  SELECT *, ROW_NUMBER() OVER (
    PARTITION BY order_id
    ORDER BY touched_at DESC, was_click DESC, priority ASC
  ) AS rn
  FROM touches
)
SELECT order_id, user_id, channel AS attributed_channel, touched_at, ordered_at
FROM ranked
WHERE rn = 1;

Publish the rule alongside the numbers. A push programme judged on last-touch will look weaker than the same programme judged on first-touch, and stakeholders comparing two reports built on different rules will reach opposite conclusions about the same campaign. The wider measurement model this feeds sits in the engagement and campaign optimization reference.

Gotchas & Edge Cases

  • The beacon must fire before routing. Send the click event with keepalive: true before calling focus() or openWindow(); both can cancel in-flight requests from the worker.
  • Same tag collapses erase click ids. Replacing a notification with the same tag discards the earlier one’s data, so its cid can never be clicked. Mint a new id per replacement and mark the superseded send as collapsed.
  • Anonymous-to-known stitching. A click from a logged-out browser writes the cookie, and the order arrives under a user id. Copy the cid from cookie to session at login so the join survives authentication.
  • Cookie lifetime caps. Some browsers cap script-written cookie lifetimes to seven days, so a 30-day max-age is not guaranteed. Set the cookie from a server response header on the landing request if you need the longer window.
  • Multiple devices, one order. A subscriber can click on their phone and buy on a laptop. Only user-level identity bridges that; without a login, accept that some push-driven revenue will be recorded as direct.
  • Do not reuse click ids across resends. A reused cid makes it impossible to tell which of two sends produced the conversion, and it silently double-counts in the join.

FAQ

Why does push traffic show up as direct in my analytics?

Because a navigation started by clients.openWindow inside a service worker has no referring document, so document.referrer is empty and referrer-based channel detection falls back to direct. Carry an explicit click id in notification.data and in the destination URL, and classify the session from that identifier rather than from the referrer.

How do I attribute a click when the service worker focuses an existing tab?

Nothing navigates in that case, so there is no URL to read. Call postMessage on the client before focusing it, have the page listen for that message, write the click id to a first-party cookie, and update the URL with history.pushState so any later pageview carries the identifier too.

Should view-through conversions count as push revenue?

Report them, but in a separate column from click-through conversions and on a much shorter window such as 12 to 24 hours from display. View-through credit is an inference that the notification was seen and acted on later, which is far weaker evidence than a recorded click, and merging the two makes the headline number impossible to interpret.