Subscription analytics for publishers: from anonymous reader to subscriber
A subscription is the conversion a publisher's ad spend is judged on, and the browser sees little of it: trials convert days later, in the billing system. Subscription analytics on Jitsu starts from the events. The funnel from first article to paid subscription lands in your warehouse, billing sends subscription_started and trial_converted, and a function passes those two to Google Ads and Meta Ads only for readers who consented to advertising. This page is part of the media and publishing guide.
TL;DR
- Billing sends
subscription_startedandtrial_convertedover the HTTP API with a server-to-server write key. - Google Ads and Meta Ads get those two events with three properties:
order_id,valueandcurrency. - A conversion without recorded advertising consent reaches neither ad platform.
- Identity stitching rewrites warehouse rows only, up to 30 days back, on Jitsu Cloud Enterprise or self-hosted Jitsu.
- The SQL below joins anonymous reading to subscribers through
identifyevents, on any plan.
Who this is for
Heads of subscriptions and performance marketing at publishers that buy Google and Meta ads to acquire subscribers, and the engineers who connect the billing backend.
The problem
An ad platform's pixel sees the checkout page at best. Trial conversions and renewals happen in billing, and blockers stop the pixel for part of the audience.
A reader who subscribes today can have read anonymously for weeks. Without joining those IDs, nobody can say which articles, paywall hits and newsletters came first.
The events
| Event | Emitted from | Required properties | PII handling |
|---|---|---|---|
page | Browser | article_id, is_paywalled | Personal query parameters stripped |
paywall_shown | Browser, app | article_id, paywall_type, meter_count | None |
registration_wall_shown | Browser | article_id, placement | None |
signed_up, then identify | Browser, app | method; userId (internal ID) | No email in the browser |
newsletter_subscribed | Browser | list_id, source_article_id | None |
checkout_started | Browser | offer_id, plan, price, currency | Card numbers never enter events |
subscription_started | Billing backend | subscription_id, plan, value, currency, trial, ad_consent | No email |
trial_converted | Billing backend | subscription_id, value, currency, ad_consent | No email |
subscription_canceled | Billing backend | subscription_id, reason, tenure_days | Warehouse only |
Keep card numbers out of events at the source; the checkout page and payment provider handle them. The media tracking plan (CSV) has every event with property types.
Sending them
In the browser, identify the reader at sign-up and on every login with your internal user ID, and send funnel events with track. Leave email out of browser traits.
jitsu.identify("u-1001", { plan: "registered" });
jitsu.track("paywall_shown", { article_id: "a-2231", paywall_type: "meter", meter_count: 5 });
The billing backend sends the conversions over the HTTP API with a server-to-server write key and Strict Mode on. Here's how it works:
- At checkout, store with the subscription: the reader's anonymous ID (the
__eventn_idcookie), the advertising consent your consent platform recorded, Meta's_fbcand_fbpcookies, and the Google click ID (gclid,gbraidorwbraid) the reader arrived with. - Send
subscription_startedwhen billing starts a trial or a subscription, andtrial_convertedon the first paid charge after a trial. - Put the stored consent in
properties.ad_consentastrueorfalse. - Pass the reader's IP address and user agent in
context. Server-to-server events do not take them from request headers. - Put the stored click IDs in
context.clientIdsand the checkout page URL incontext.page.url. The Google Ads destination matches the conversion on its click ID, and the Meta Ads destination sendsfbc,fbpand the page URL asevent_source_url.
curl --location 'https://events.example.com/api/s/track' \
--header 'Content-Type: application/json' \
--header 'X-Write-Key: keyId:keySecret' \
--data-raw '{"type":"track","event":"trial_converted","userId":"u-1001","anonymousId":"7c1e9a52-0d4b-4a8e-9f3a-2b6f1c9d8e01","properties":{"subscription_id":"sub_8812","plan":"digital-annual","value":89,"currency":"EUR","ad_consent":true},"context":{"ip":"203.0.113.7","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)","clientIds":{"gclid":"EAIaIQobChMIexample","fbc":"fb.1.1792560000000.IwAR0example","fbp":"fb.1.1792560000000.1098765432"},"page":{"url":"https://www.example.com/subscribe"}}}'
The function
Attach it to the billing stream's Google Ads connection with AD_PLATFORM=google-ads and to its Meta Ads connection with AD_PLATFORM=meta. For each event:
- Anything other than
subscription_startedandtrial_convertedis dropped: page views, video and engagement events included. - A conversion without
ad_consent: trueis dropped. - Conversions without a subscription ID, a numeric value or a three-letter currency are dropped with a warning in Live Events.
- The rest leave with three properties,
order_id,valueandcurrency, and withoutcontext.traits. Theorder_idis the subscription ID and the event name, such assub_8812:trial_converted, so the trial start and the first paid charge on one subscription carry different IDs. Article IDs, sections, plan names and email stay in the warehouse. - On the Meta connection, the event takes Meta's standard event name:
StartTrialfor a trial start,Subscribefor a paid subscription (Meta Pixel reference).
Build and test it with jitsu-cli: it is TypeScript, and the UI editor takes JavaScript. Destination setup: Google Ads docs, Meta Ads.
import { JitsuFunction } from "@jitsu/protocols/functions";
import { AnalyticsServerEvent } from "@jitsu/protocols/analytics";
export const config = {
slug: "ad-conversions",
name: "Subscription conversions for ad platforms",
description: "Lets consented subscription conversions through to ad platforms with three properties and no traits",
};
type Props = {
// "google-ads" or "meta": the destination on this connection
AD_PLATFORM: string;
};
const CONVERSIONS = ["subscription_started", "trial_converted"];
const adConversions: JitsuFunction<AnalyticsServerEvent, Props> = async (event, { log, props }) => {
// Only the two conversions reach an ad platform. Page views, video and engagement events stop here.
if (event.type !== "track" || !CONVERSIONS.includes(event.event ?? "")) {
return "drop";
}
const p = event.properties ?? {};
// ad_consent is the advertising consent your backend recorded for this reader at checkout.
if (p.ad_consent !== true) {
return "drop";
}
const currency = typeof p.currency === "string" ? p.currency.toUpperCase() : "";
const valid =
typeof p.subscription_id === "string" &&
p.subscription_id !== "" &&
typeof p.value === "number" &&
Number.isFinite(p.value) &&
/^[A-Z]{3}$/.test(currency);
if (!valid) {
log.warn(`${event.event} ${event.messageId}: needs subscription_id, numeric value and currency, dropped`);
return "drop";
}
// Only these three properties reach the ad platform. Articles, sections, plan names,
// cancellation reasons and traits such as email stay in the warehouse. The order ID includes
// the event name, so a trial start and its first paid charge do not share one.
event.properties = { order_id: `${p.subscription_id}:${event.event}`, value: p.value, currency };
if (event.context?.traits) {
delete event.context.traits;
}
if (props.AD_PLATFORM === "meta") {
event.event = event.event === "subscription_started" && p.trial === true ? "StartTrial" : "Subscribe";
}
return event;
};
export default adConversions;
Video events never pass this function. The United States Video Privacy Protection Act restricts disclosing which videos a person requested or obtained together with information that identifies them.
Identity stitching scope
On Jitsu Cloud Enterprise and on self-hosted Jitsu, identity stitching rewrites a reader's earlier anonymous events with their user ID and traits when they identify, up to 30 days back. Six warehouses support it: ClickHouse, BigQuery, Postgres, Redshift, Snowflake and MySQL. It requires deduplication.
What Google Ads, Meta or analytics tools already received does not change.
Safari shortens the window. The anonymous ID cookie set by JavaScript lasts 7 days there, so a Safari reader who returns after a week starts a new anonymous ID unless Jitsu's ID endpoint is deployed (see the server-side tracking page).
Querying it
The query joins through identify events, which carry both anonymous_id and user_id, so it runs on any plan. With identity stitching on, earlier rows already carry the user ID and the join still holds.
-- BigQuery: what new subscribers did in the 30 days before subscribing
WITH subs AS (
SELECT user_id, MIN(timestamp) AS subscribed_at
FROM `your_dataset.events`
WHERE type = 'track'
AND event = 'subscription_started'
AND timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
GROUP BY user_id
),
id_map AS (
-- every anonymous ID a reader has identified from, on any plan
SELECT DISTINCT user_id, anonymous_id
FROM `your_dataset.events`
WHERE type = 'identify'
AND user_id IS NOT NULL
AND anonymous_id IS NOT NULL
),
history AS (
SELECT s.user_id, e.message_id, e.type, e.event, e.article_id
FROM subs AS s
JOIN id_map AS m ON m.user_id = s.user_id
JOIN `your_dataset.events` AS e
ON e.anonymous_id = m.anonymous_id
AND e.timestamp BETWEEN TIMESTAMP_SUB(s.subscribed_at, INTERVAL 30 DAY) AND s.subscribed_at
),
per_reader AS (
SELECT
s.user_id,
COUNT(DISTINCT IF(h.type = 'page', h.article_id, NULL)) AS articles_read,
COUNT(DISTINCT IF(h.event = 'paywall_shown', h.message_id, NULL)) AS paywalls_seen,
COUNT(DISTINCT IF(h.event = 'newsletter_subscribed', h.message_id, NULL)) AS newsletter_signups
FROM subs AS s
LEFT JOIN history AS h ON h.user_id = s.user_id
GROUP BY s.user_id
)
SELECT
COUNT(*) AS new_subscribers,
APPROX_QUANTILES(articles_read, 2)[OFFSET(1)] AS median_articles_read,
APPROX_QUANTILES(paywalls_seen, 2)[OFFSET(1)] AS median_paywalls_seen,
ROUND(SAFE_DIVIDE(COUNTIF(newsletter_signups > 0), COUNT(*)), 3) AS share_with_newsletter_signup,
ROUND(SAFE_DIVIDE(COUNTIF(articles_read = 0 AND paywalls_seen = 0), COUNT(*)), 3) AS share_with_no_history
FROM per_reader;
share_with_no_history is the share of new subscribers with no reading linked to them in the 30 days before. A high value points at Safari readers without the ID endpoint, or at readers who subscribed on a second device.
Destinations
| Destination | Receives |
|---|---|
| BigQuery (or another warehouse) | Every event, including cancellations and reasons |
| Google Ads | Consented subscription_started and trial_converted, three properties |
| Meta Ads | Consented StartTrial and Subscribe, three properties |
| Mixpanel, Amplitude or PostHog | Funnel events without email |
What others in the industry do
Public write-ups from other publishers, cited as industry examples. They do not describe Jitsu.
- Reach plc, which owns more than 200 UK publications, named 10 million registered users as its next target in December 2021 (Digiday interview).
Example implementation (illustrative)
Illustrative example: not a Jitsu customer. A digital magazine sells annual subscriptions with a 14-day trial and buys Google and Meta ads.
- Web and apps:
identifyat sign-up and login, funnel events withtrack, no email in traits. - Billing: the backend sends
subscription_startedwhen a trial starts andtrial_convertedon the first annual charge, each with the anonymous ID andad_consentstored at checkout. - Google Ads: the billing stream's connection carries
ad-conversionswithAD_PLATFORM=google-ads. - Meta Ads: the billing stream's connection carries
ad-conversionswithAD_PLATFORM=metaand receivesStartTrialandSubscribe. - Warehouse: BigQuery receives every event, and the query above compares the reading history of new subscribers each week.
Where Jitsu does not fit
- Jitsu is not a consent platform. Your backend records consent and sends it with each conversion; the function reads what you send.
- Identity stitching on Jitsu Cloud needs the Enterprise plan, reaches 30 days back, and rewrites warehouse rows only.
- Jitsu Cloud is hosted in the United States. The security page covers SOC 2, the DPA and the EU-U.S. Data Privacy Framework; pricing lists which plan includes what.
Questions people ask
Which events reach Google Ads and Meta? Only subscription_started and trial_converted, and only when ad_consent is true. They carry order_id, value and currency. On the Meta connection they are renamed StartTrial and Subscribe.
Does email reach the ad platforms? Not through this function. It removes context.traits from every event it passes, and the billing call on this page sends no email.
Where does identity stitching apply? Identity stitching rewrites rows in the warehouse only: ClickHouse, BigQuery, Postgres, Redshift, Snowflake and MySQL, up to 30 days back. It is part of Jitsu Cloud Enterprise and of self-hosted Jitsu.
What happens to readers who declined advertising consent? Their conversions carry ad_consent false, and the function drops them on the Google Ads and Meta connections. The warehouse still receives their events under the consent rules that govern the warehouse connection.
Get started with Jitsu
Get started on the free plan with 200k active events a month, or book a demo to talk through identity stitching and Enterprise terms.