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 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.
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.
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:
- 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. - Discard touches whose own window has already expired — a view-through push display older than 24 hours is not eligible.
- Order the survivors by timestamp and take the latest. That channel receives 100% of the credit.
- 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.
- Write one row per order into an
order_attributiontable. 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: truebefore callingfocus()oropenWindow(); both can cancel in-flight requests from the worker. - Same
tagcollapses erase click ids. Replacing a notification with the sametagdiscards the earlier one’sdata, so itscidcan 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
cidfrom 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-ageis 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
cidmakes it impossible to tell which of two sends produced the conversion, and it silently double-counts in the join.
Related
- Back to Push Delivery Analytics Instrumentation — the event pipeline that produces the send, display and click rows this join consumes.
- Tracking push delivery vs display rates — establishes the display event that opens the view-through window.
- Measuring push notification click-through rate — the click definition and denominator used upstream of attribution.
- Cart abandonment push notifications — a campaign where the attribution window choice changes the reported result substantially.
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.