Optimize ad spend on funded accounts, not signups
Your Meta and Google campaigns optimize on the conversion you report, and when that conversion is a signup, they keep finding people who sign up and never fund. The event worth sending is the funded account, and it settles in your backend after the funding page has closed, so a pixel on that page only ever sees the attempt.
A pixel on a funding page has a second problem: it can read whatever the page shows. The Swedish data protection authority fined Avanza Bank about €1.3 million after Meta pixel features switched on by mistake sent customers' securities holdings, loan amounts, account numbers and personal identity numbers to Meta (EDPB).
Jitsu sends the conversion server-side instead. Your backend posts Account Funded with the user's email and phone, two functions cut it down to a fixed set of fields and drop anything without ad consent, and the Meta Ads (Facebook & Instagram) and Google Ads destinations send what is left. The funding page carries no pixel.
TL;DR
Account Fundedgoes from the backend to Jitsu over the HTTP API, once per user.- Two functions run on both ad connections: a guardrail that strips regulated fields by name and drops any event with a card number outside an ID field, then an allowlist that keeps page views, screen views and the two conversions.
- Both ad destinations SHA-256 hash email and phone before they leave Jitsu, and neither receives your raw user ID.
- The ClickHouse query on this page gives each campaign's signup-to-funded rate, for cost per funded account.
The events on the ad connections
| Event | Sent from | Required properties | PII handling |
|---|---|---|---|
page | Browser, Jitsu JS | URL with gclid, gbraid or wbraid; Meta's _fbc and _fbp cookies | query parameters other than click IDs removed |
identify | Browser, app, backend | userId (your internal ID), traits email and phone | carried on the conversion in context.traits; identify itself is dropped here |
Account Created | Backend | signup_method | no amounts |
Account Funded | Backend, once per user | value, currency | value and currency; email and phone hashed by both destinations |
The fintech hub shows where the rest of the fintech tracking plan (CSV) goes instead. Account Funded also closes the onboarding and KYC funnel in the warehouse.
| Meta Ads | Google Ads | |
|---|---|---|
| Events | Page and screen views, Account Created, Account Funded | Account Created and Account Funded, each mapped to a conversion action |
| Matching | Hashed email and phone, a SHA-256 of userId, IP address, user agent, the fbc and fbp cookies | gclid, gbraid or wbraid, hashed email and phone, IP address, user agent |
| Value | value and currency | value and currency |
| Consent | Events without ad_user_data consent never reach it | The same, and ad_user_data and ad_personalization go with every conversion |
The value is the first deposit's amount, because that is what both platforms bid on. If your policy keeps exact amounts out of third-party tools, send a plain number, such as the band's midpoint or a fixed value per conversion, never a band label: the allowlist sends whatever number it parses from value.
The guardrail that runs first
One function runs first on every connection, the warehouse and the ad connections alike. It removes regulated identifiers by key name at any depth, and drops any event where a value outside an ID field matches a 13 to 19 digit card pattern that passes the Luhn check, logging the field name to Live Events, never the value. Free text such as support messages and transfer references is where card numbers turn up.
// Removed at any depth. Keys are compared lowercased, with "_", "-" and spaces stripped.
// Add your own field names to this list.
const FORBIDDEN_KEYS = new Set([
"cardnumber", "ccnumber", "creditcardnumber", "pan", "cvv", "cvv2", "cvc", "securitycode",
"cardexpiry", "expirydate", "expdate", "iban", "accountnumber", "accountno", "routingnumber", "sortcode",
"ssn", "socialsecuritynumber", "nationalid", "taxid", "passportnumber", "documentnumber",
"dateofbirth", "dob", "birthdate", "password", "otp", "pin",
]);
// Keys whose values are IDs and are not scanned: id, deposit_id, messageId, transactionID.
const ID_KEY = /(^|[_\-\s])(id|Id|ID)$|[a-z0-9](Id|ID)$/;
// A run of 13 to 19 digits starting with 2-6, with optional single spaces or dashes between digits.
// Every digit group is tried as a start, so a date or an amount in front cannot hide a card number.
const CARD_START = /(?<!\d)(?<!\+)(?=([2-6](?:[ -]?\d){12,18}))/g;
function passesLuhn(digits) {
let sum = 0;
for (let i = 0; i < digits.length; i++) {
let d = Number(digits[digits.length - 1 - i]);
if (i % 2 === 1) d = d * 2 > 9 ? d * 2 - 9 : d * 2;
sum += d;
}
return sum % 10 === 0;
}
function hasCardNumber(value) {
for (const match of value.matchAll(CARD_START)) {
const run = match[1];
const after = value[match.index + run.length];
let digits = "";
for (let i = 0; i < run.length; i++) {
if (!/\d/.test(run[i])) continue;
digits += run[i];
const next = i + 1 < run.length ? run[i + 1] : after;
const groupEnds = next === undefined || !/\d/.test(next);
if (groupEnds && digits.length >= 13 && passesLuhn(digits)) return true;
}
}
return false;
}
// Copies the value without forbidden keys and records where a card number appears.
function scrub(value, path, hits) {
if (Array.isArray(value)) return value.map((v, i) => scrub(v, `${path}[${i}]`, hits));
if (value !== null && typeof value === "object") {
const out = {};
for (const [key, v] of Object.entries(value)) {
if (FORBIDDEN_KEYS.has(key.toLowerCase().replace(/[_\-\s]/g, ""))) continue;
out[key] = ID_KEY.test(key) ? v : scrub(v, path ? `${path}.${key}` : key, hits);
}
return out;
}
if ((typeof value === "string" || typeof value === "number") && hasCardNumber(String(value))) {
hits.push(path);
}
return value;
}
export default async function piiGuardrail(event, { log }) {
if (!event || typeof event.type !== "string") {
log.warn("pii-guardrail: dropped an event with no type");
return "drop";
}
const hits = [];
const clean = scrub(event, "", hits);
if (hits.length > 0) {
log.warn(`pii-guardrail: dropped ${event.event ?? event.type}, card number pattern at ${hits.join(", ")}`);
return "drop";
}
return clean;
}
Keep card numbers out of events at the source and tracking scripts off card-entry pages. Functions run after Jitsu receives the event, and Jitsu Cloud's event backups keep events as received, so the guardrail is a second line of defense behind the source.
The allowlist on the ad connections
import { hash } from "crypto";
const CONVERSIONS = new Set(["Account Created", "Account Funded"]);
const REQUIRED_CONSENT = ["ad_user_data"]; // keys in context.consent.categoryPreferences; [] turns the check off
const URL_CLICK_IDS = ["gclid", "gbraid", "wbraid", "dclid", "fbclid"]; // the only query parameters kept in page URLs
const CLIENT_IDS = ["gclid", "gbraid", "wbraid", "dclid", "fbc", "fbp"]; // the only keys kept from context.clientIds
function hasAdConsent(event) {
const prefs = event.context?.consent?.categoryPreferences ?? {};
return REQUIRED_CONSENT.every(key => prefs[key] === true || prefs[key] === "GRANTED");
}
function pick(source, keys) {
const out = {};
for (const key of keys) {
if (source?.[key] !== undefined && source?.[key] !== null) out[key] = source[key];
}
return out;
}
// Removes every query parameter except click IDs, and the fragment.
function cleanUrl(url) {
if (typeof url !== "string") return undefined;
try {
const u = new URL(url);
for (const name of [...u.searchParams.keys()]) {
if (!URL_CLICK_IDS.includes(name)) u.searchParams.delete(name);
}
u.hash = "";
return u.toString();
} catch {
return undefined;
}
}
export default async function adConversionAllowlist(event, { log }) {
const isPageView = event.type === "page" || event.type === "screen";
const isConversion = event.type === "track" && CONVERSIONS.has(event.event ?? "");
if (!isPageView && !isConversion) return "drop";
if (!hasAdConsent(event)) {
if (isConversion) log.info(`ad-conversion-allowlist: dropped ${event.event}, no ad consent`);
return "drop";
}
const context = event.context ?? {};
const value = parseFloat(String(event.properties?.value));
const currency = event.properties?.currency;
return {
type: event.type,
event: event.event,
messageId: event.messageId,
timestamp: event.timestamp,
anonymousId: event.anonymousId,
// Only a SHA-256 of the internal user ID leaves this connection.
userId: event.userId ? hash("sha256", String(event.userId)) : undefined,
properties: isConversion && Number.isFinite(value) ? pick({ value, currency }, ["value", "currency"]) : {},
context: {
ip: context.ip,
userAgent: context.userAgent,
consent: context.consent,
traits: pick(context.traits, ["email", "phone"]),
clientIds: pick(context.clientIds, CLIENT_IDS),
page: context.page ? { url: cleanUrl(context.page.url) } : undefined,
},
};
}
In order, it:
- Keeps page and screen views and the two conversions, and drops everything else. Page views stay because Google Ads watches every event on its connection for a click ID.
- Rebuilds the event from a fixed list of fields, so a property added to
Account Fundedlater never reaches either ad platform. - Leaves only click-ID query parameters in page URLs, and only email and phone in traits.
Page views already sent to the warehouse add no active events on the ad connections (pricing).
Setting it up
- Send the conversion from the backend when the first deposit settles:
curl --location 'https://your-jitsu-domain.com/api/s/track' \
--header 'Content-Type: application/json' \
--header 'X-Write-Key: keyId:keySecret' \
--data-raw '{
"type": "track",
"event": "Account Funded",
"messageId": "account-funded-dep_5521",
"userId": "u_8f3a2c",
"timestamp": "2026-09-05T12:00:00.000Z",
"properties": {
"value": 250,
"currency": "USD",
"funding_method": "bank_transfer",
"days_since_signup": 3
},
"context": {
"ip": "203.0.113.42",
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)",
"page": { "url": "https://www.example.com/fund" },
"traits": { "email": "ana@example.com", "phone": "+15555550100" },
"consent": { "categoryPreferences": { "ad_user_data": true, "ad_personalization": true } }
}
}'
The backend passes the IP address and user agent stored from the user's session, the URL of the page where the user funded the account in context.page.url, the email and phone for matching, and the consent it holds for that user. Meta uses context.page.url as the conversion's source URL, so send it on every conversion. The first deposit's ID makes a good messageId: a retry then deduplicates in the warehouse, and the raw user ID stays out of it.
- Carry consent. In the browser, consent comes from your consent tool through
privacy.consentCategories(consent management), with the category namesad_user_dataandad_personalization. Server-side events carry consent only when your backend adds it, so store what the consent tool recorded with the user and send it on every conversion. Open one browser event in Live Events and confirmcontext.consent.categoryPreferencesis present. - Add both functions in the Jitsu UI function editor: the guardrail first and the allowlist second, on the Meta Ads connection and on the Google Ads connection. Put long numeric IDs under a key the guardrail's scan skips, one named
id, ending in_id, or ending inIdorIDright after a lowercase letter or digit (deposit_id,transactionId), and store phone numbers in E.164 with the leading+, so neither is mistaken for a card number. - Configure the destinations. On Google Ads, set Events to
Account Created,Account Funded, Phone Trait Name tophone, and map both events to their conversion actions under Conversion Actions, oneeventName=conversionActionIdper line (Google Ads docs). On Meta Ads, set the phone field tophone. - Test before going live. POST a sample conversion to the synchronous functions endpoint,
/api/funcs/{connectionId}, which responds with the event as your functions return it. Then send one after a signed-in page view with Validate Only switched on, and confirm in Live Events that Google's response shows the click ID.
Click IDs on conversions sent at signup
In the browser, Jitsu JS adds Meta's _fbc and _fbp cookies to context.clientIds as fbc and fbp (JavaScript reference), and the Google Ads destination finds click IDs in the landing page URL or Google's conversion-linker cookie. It keeps a click ID against the user for 90 days, unless Remember Click IDs is turned off.
Click IDs seen on an anonymous landing page are stored under the anonymousId, and reach the hashed userId once an event carrying both arrives. Any signed-in page view does that, because the allowlist passes the anonymousId and the hashed userId. A conversion sent before such a view, such as Account Created at signup, finds no stored click ID unless the backend adds the anonymousId to the call, and matches on the hashed email and phone instead.
Cost per funded account
On ClickHouse, the first-touch campaign is context_campaign_name on the user's earliest page view. Signups whose landing-page views were anonymous land in (no campaign) unless identity stitching is on (an Enterprise feature on Jitsu Cloud, with no plan restriction self-hosted, that reaches 30 days of warehouse rows; how it works).
-- ClickHouse. Signups in September 2026 that funded within 14 days, by first-touch UTM campaign.
WITH per_user AS (
SELECT
user_id,
argMinIf(context_campaign_name, timestamp, type = 'page') AS first_campaign,
minIf(timestamp, event = 'Account Created') AS created_at,
minIf(timestamp, event = 'Account Funded') AS funded_at
FROM events FINAL
WHERE user_id != ''
AND timestamp >= '2026-08-01' AND timestamp < '2026-10-15'
AND (type = 'page' OR event IN ('Account Created', 'Account Funded'))
GROUP BY user_id
)
SELECT
if(first_campaign = '', '(no campaign)', first_campaign) AS campaign,
count() AS signups,
countIf(funded_at BETWEEN created_at AND created_at + INTERVAL 14 DAY) AS funded_14d,
round(funded_14d / signups, 3) AS signup_to_funded
FROM per_user
WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01'
GROUP BY campaign
ORDER BY signups DESC, campaign;
Join the result to your ad spend by campaign name for cost per funded account.
Questions people ask
Can campaigns keep optimizing on signups while funded accounts build up? Yes. Account Created reaches both platforms as its own conversion, so a campaign can stay on signups and switch to Account Funded when you are ready.
Do I still need click IDs if I send email and phone? Send both. A click ID ties the conversion to the exact ad click, and hashed email and phone give Google a second way to match when the click ID is missing.
Can Meta's pixel stay on the rest of the site? Yes, on pages that show no account data. Keep it off logged-in and funding pages, and stop it firing its own funded-account event, so Meta gets the conversion once.
Get started with Jitsu
Start on the Free plan, or book a demo to go through the ad connections with your own events.