Funded-account conversions from your backend to the Meta Conversions API and Google Ads
Ad platforms optimize toward the event you send them, and the fintech event worth sending to the Meta Conversions API or Google Ads, a funded account, settles in your backend, where a browser pixel sees it only if the funding page fires one. Jitsu sends it server-side instead: your backend posts Account Funded with the user's email and phone in context.traits, an allowlist function cuts the event down to a fixed set of fields and drops anything without ad consent, and the Meta Ads (Facebook & Instagram) and Google Ads destinations send what is left. Jitsu's Meta Ads destination was listed as Facebook Conversions API until the directory renamed it, and Google Ads hashes email and phone with SHA-256 before sending.
This page is for the performance marketing lead or marketing data engineer at a neobank, broker, lender or payments app, and is part of the fintech hub.
TL;DR
Account Fundedgoes from the backend to Jitsu over the HTTP API, once per user.- An allowlist function keeps page views, screen views and two conversions, and drops every other event.
- Google Ads normalizes and SHA-256 hashes email, phone and names before they leave Jitsu.
- The function replaces
userIdwith its SHA-256 hash, so neither ad platform receives the raw ID. - Meta Ads and Google Ads are the live advertising destinations.
The problem in plain words
A campaign that reports signups finds more people who sign up, whether or not they ever fund. On the funding page, a pixel can read everything else 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 social security numbers to Meta.
The events
| Event | Sent from | Required properties | PII handling |
|---|---|---|---|
page | Browser, Jitsu JS | URL with gclid, gbraid or wbraid; Meta's _fbc and _fbp cookies in context.clientIds | 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 only; Google Ads hashes email and phone |
Screen views pass with page views. identify calls are dropped on this connection, and the conversion carries email and phone in context.traits. The other 30 events in the fintech tracking plan (CSV) stay off the ads connection.
What each destination sends
Google Ads, from the docs
The Google Ads destination docs state:
- Events:
track,pageandscreen, chosen by the Events setting (empty meanstrackonly). Conversion Actions maps event names to conversion actions, oneeventName=conversionActionIdper line. - Matching:
gclid,gbraidorwbraidfrom the landing page URL or Google's conversion-linker cookie,traits.email, the trait named in Phone Trait Name, names, country, postal code, IP address and user agent. - Hashing: email, phone and names are normalized and SHA-256 hashed before they leave Jitsu. Country and postal code go as they are.
- Click IDs: every event on the connection is watched for one, and it is kept against that user for 90 days unless Remember Click IDs is turned off.
- Value and consent: the value comes from
properties.total,valueorrevenue, andad_user_dataandad_personalizationfromcontext.consent.categoryPreferencesgo with every conversion.
Meta Ads
Meta Ads has no docs page, so the allowlist function decides what reaches it: page and screen views, the two conversions with value and currency, email and phone in context.traits, a SHA-256 of userId, IP address, user agent and the fbc and fbp cookies. To see exactly what your functions hand on, POST a sample conversion to the synchronous functions endpoint, /api/funcs/{connectionId}, which responds with the event as your functions return it.
Sending them
In the browser, Jitsu JS adds Meta's _fbc and _fbp cookies to context.clientIds (JavaScript reference), and the Google Ads destination finds click IDs in the landing page URL. Consent comes from your consent tool through privacy.consentCategories (consent management), with the category names ad_user_data and ad_personalization.
The Google Ads destination reads ad_user_data and ad_personalization from context.consent.categoryPreferences (Google Ads docs), and the function checks the same field. Open one browser event in Live Events and confirm the field is present before relying on the check.
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-u_8f3a2c",
"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. The Meta Ads destination sends context.page.url as event_source_url and defaults to the website action source, so a conversion without it has no source URL. One fixed messageId per user means a retry is deduplicated in the warehouse.
The function
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.
- Requires
ad_user_dataconsent. SetREQUIRED_CONSENTto[]to turn the check off. - Rebuilds the event from a fixed list of fields, so a property added to
Account Fundedlater never reaches either ad platform. - Replaces
userIdwith its SHA-256 hash from thecryptomodule the functions runtime provides, so neither ad platform receives the raw ID. - Leaves only click-ID query parameters in page URLs, and only
emailandphonein traits.
Paste it into the Jitsu UI function editor, which allows the crypto import, and put it after the PII guardrail from the fintech hub on the ads connection. On Google Ads, set Events to Account Created,Account Funded and Phone Trait Name to phone, and map Account Funded to its conversion action under Conversion Actions.
Every event on the connection carries the same hashed userId, and the Google Ads destination keeps a click ID it sees against that user for 90 days (Google Ads docs). A click ID seen on an anonymous landing page is stored under the anonymousId, and it reaches the hashed userId once an event carrying both arrives. A signed-in page view does that, because this function passes the anonymousId and the hashed userId; identify is dropped here. 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 relies on the hashed email and phone instead. Check with Validate Only switched on: a conversion sent after a signed-in page view should carry the click ID, and one sent before it should rely on email and phone. Read Google's response in Live Events.
Page views already sent to the warehouse add nothing to the bill, because an event counts once however many destinations it reaches (pricing).
Querying it
ClickHouse, against the default events table. The first-touch campaign is context_campaign_name on the user's earliest page view, which carries the user ID only after identity stitching (Enterprise on Jitsu Cloud, up to 30 days back, warehouses only) or for page views made while signed in.
-- 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.
Example implementation (illustrative): an online broker moving off a funding-page pixel
Illustrative example: not a Jitsu customer.
An online broker runs Meta and Google campaigns that optimize on signups, with Meta's pixel on every page of its web app. It removes the pixel from the funding flow, sends Account Created and Account Funded from its backend through Jitsu with the allowlist function on the ads connection, and switches both ad accounts to optimize on Account Funded. The funding pages now send nothing to either platform, and the query above shows the broker what each campaign's signups go on to fund.
That event also drives the onboarding and KYC funnel in the warehouse.
Where Jitsu does not fit
- Advertising destinations: Meta Ads and Google Ads are the live advertising destinations. Other ad platforms in the directory are Coming soon.
- Identity stitching rewrites warehouse rows only. A conversion already sent to Meta or Google is not updated afterwards.
- Consent is only as complete as the event: server-side events carry no consent object unless your backend adds one.
- Raw events: functions run after Jitsu receives the event, so the event with its traits passes through Jitsu's pipeline, and Jitsu Cloud's event backups, before the function removes anything.
Questions people ask
Does Jitsu hash email and phone for Meta and Google? Google Ads normalizes and SHA-256 hashes email, phone and names before sending, as its docs state. On the ads connection, the allowlist function replaces userId with its SHA-256 hash, so the raw internal ID is never sent.
Why not send every event to the ad connection? An allowlist function keeps the ad connection to page views and two conversions with value and currency, so a property added to an event later never reaches either ad platform.
Do I still need click IDs if I send email and phone? Send both. Google matches on click IDs, hashed email and phone, IP address and user agent, and the Google Ads destination keeps a click ID it saw on a landing page for 90 days and replays it onto a later conversion once an event has carried both the anonymous ID and the user ID.
What happens to conversions without consent? The allowlist function drops them before they reach either destination. Google Ads also forwards ad_user_data and ad_personalization from context.consent.categoryPreferences with every conversion it sends.
Get started with Jitsu
Book a demo to walk through the ads connection on your own events. To start on Jitsu Cloud, Get started and send a test conversion with Validate Only switched on.