⚡ ⚡ Learn how Investing.com uses Jitsu to move data faster

Fintech onboarding funnel tracking, from signup to first deposit

Fintech onboarding comes down to seven events, from Account Created to Account Funded, and five of them can only come from your backend. The browser or app sends the steps a user takes, and the backend sends the decisions, because KYC outcomes arrive from the vendor's webhook and deposits settle in your ledger. Jitsu writes all seven to one events table in your warehouse, and one ClickHouse query turns that table into a funnel by KYC provider and first-touch source.

This page is for the growth analytics lead, data engineer or product analyst at a neobank, broker, lender or payments app. It is part of the fintech hub.

TL;DR

  • KYC Approved, KYC Rejected, Deposit Completed and Account Funded come from the backend over the HTTP API with a server-to-server write key.
  • Jitsu deduplicates on message_id, so a retried webhook that reuses the vendor's decision ID as messageId adds no second row.
  • A contract function keeps the plan's properties and routes a malformed funnel event to its own table.
  • Identity stitching links the first-touch page view to the account: Enterprise on Jitsu Cloud, warehouses only, up to 30 days back.
  • The funnel is one ClickHouse query over the events table.

The problem in plain words

An anonymous visitor reads the marketing site, the same person signs up in the app, and the KYC vendor and the payment system decide what happens next on the backend. Product analytics tools see the clicks. A KYC decision or a settled deposit reaches them only if the backend sends it, and the signup joins back to the ad that brought the visitor only if something links the anonymous ID to the user ID.

The events

EventSent fromRequired propertiesPII handling
identifyBrowser, app, backenduserId (your internal ID), trait emailhash_for_ads; never national ID or date of birth
Account CreatedBackendsignup_methodnone
KYC StartedBrowser or appkyc_providernone
KYC SubmittedBrowser, app or backendkyc_provider, attempt_numbernone; never document numbers, images or selfie data
KYC ApprovedBackend, from the KYC webhookkyc_provider, review_typenone
KYC RejectedBackend, from the KYC webhookkyc_provider, reason_categorynone; never free-text reasons with personal data
Deposit CompletedBackenddeposit_id, amount, currency, funding_methodband_for_third_party
Account FundedBackend, once per uservalue, currencyhash_for_ads

The fintech tracking plan (CSV) has all 35 events, with a source column (client, server, or client or server) and two PII columns. pii_handling is none, hash_for_ads (email and phone hashed before an ad platform), band_for_third_party (the exact amount in the warehouse, a band or nothing in third-party tools) or strip_query_params (page URLs). The never_send_fields column names the fields no event may carry.

Use your internal user ID as userId on every event. An account number or national ID used as userId travels to every destination.

Sending them

From the browser or web app, after signup, with the JavaScript library:

jitsu.identify("u_8f3a2c", { email: "ana@example.com" });
jitsu.track("KYC Started", { kyc_provider: "vendor_a", attempt_number: 1 });

From the backend, with a server-to-server write key and the HTTP API. This is the call a KYC webhook handler makes after an approval:

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": "KYC Approved",
  "messageId": "kyc-decision-7781",
  "userId": "u_8f3a2c",
  "timestamp": "2026-09-03T08:00:00.000Z",
  "properties": {
    "kyc_provider": "vendor_a",
    "review_type": "auto",
    "time_to_decision_seconds": 79200
  },
  "context": {}
}'

The messageId comes from the vendor's decision ID. Jitsu deduplicates on message_id, so a retried webhook does not create a second row.

Strict Mode on the server-to-server stream accepts only requests with a valid write key (server-to-server docs). Jitsu does not read context.ip or context.userAgent from a server's request, so send them yourself when you want them; a KYC decision has no user request behind it, and this example leaves them out.

Apps use Segment's open-source libraries with the host set to your Jitsu domain: iOS, Android, React Native. An app install has its own anonymous ID, so call identify in the app as well as on the web.

Shaping them before they land

The warehouse connection runs one function after the PII guardrail from the fintech hub, and it holds the funnel events to the tracking plan. It keeps the listed properties and removes everything else (reviewer notes, document numbers, raw vendor payloads), maps unknown rejection reasons to other, drops traffic from the KYC vendor's sandbox, and routes an event that is missing a required property to its own table with JITSU_TABLE_NAME, so the funnel table stays clean and the breakage is queryable.

// Event name -> [required properties, optional properties], copied from fintech-tracking-plan.csv.
const PLAN = {
  "Account Created": [["signup_method"], ["product", "country", "referral_code_used"]],
  "KYC Started": [["kyc_provider"], ["attempt_number"]],
  "KYC Submitted": [["kyc_provider", "attempt_number"], ["document_type_category"]],
  "KYC Approved": [["kyc_provider", "review_type"], ["time_to_decision_seconds"]],
  "KYC Rejected": [["kyc_provider", "reason_category"], ["attempt_number", "is_final"]],
  "Deposit Completed": [["deposit_id", "amount", "currency", "funding_method"], ["is_first_deposit"]],
  "Account Funded": [["value", "currency"], ["funding_method", "days_since_signup"]],
};

const REASONS = new Set([
  "document_unreadable", "document_expired", "data_mismatch", "sanctions_screening", "risk_review", "other",
]);

const VIOLATIONS_TABLE = "kyc_contract_violations";

export default async function kycFunnelContract(event, { log }) {
  const spec = event.type === "track" && Object.hasOwn(PLAN, event.event ?? "") ? PLAN[event.event] : undefined;
  if (!spec) return; // not a funnel event: passes through unchanged

  const props = event.properties && typeof event.properties === "object" ? event.properties : {};
  if (props.environment === "sandbox") return "drop"; // KYC vendor sandbox traffic

  const [required, optional] = spec;
  const kept = {};
  for (const key of [...required, ...optional]) {
    if (props[key] !== undefined && props[key] !== null && props[key] !== "") kept[key] = props[key];
  }
  if (typeof kept.reason_category === "string" && !REASONS.has(kept.reason_category)) {
    kept.reason_category = "other";
  }

  const missing = required.filter(key => !(key in kept));
  if (missing.length > 0) {
    log.warn(`kyc-funnel-contract: ${event.event} is missing ${missing.join(", ")}`);
    return { ...event, properties: { ...kept, missing_properties: missing.join(",") }, JITSU_TABLE_NAME: VIOLATIONS_TABLE };
  }
  return { ...event, properties: kept };
}

Events outside the plan return nothing, which in a Jitsu function means they pass unchanged (return values). The function makes no fetch call, so the sandbox events it drops are not billed (pricing). Its warning lists the missing properties in Live Events, and the kyc_contract_violations table keeps the events themselves.

Identity stitching: scope and plan

Identity stitching is what attaches the first-touch page view to the funded account. When a visitor identifies, Jitsu rewrites that visitor's earlier anonymous events in the warehouse with the user ID and traits, up to 30 days back. Four limits apply:

  • Six warehouses support it: ClickHouse, BigQuery, PostgreSQL, Redshift, Snowflake and MySQL. Analytics tools and ad platforms do not receive the rewritten history.
  • On Jitsu Cloud it is part of the Enterprise plan. Self-hosted Jitsu has no plan restriction, and needs MongoDB for it.
  • The anonymous ID belongs to one browser or one app install, and the browser stores it in a cookie only with consent. Identify in every client the user signs in on.
  • It relies on deduplication, and it is switched on per connection in the connection's Advanced settings (identity stitching docs).

Without stitching, the funnel still works from Account Created onward, because every backend event carries the user ID. The first-touch column then comes back as (none) for visitors whose page views were anonymous. Product details: identity stitching.

Querying it

ClickHouse dialect, against the default single events table in a ClickHouse destination. Track properties are columns at the root (kyc_provider), UTM parameters arrive as context_campaign_source, and FINAL makes the ReplacingMergeTree table return one row per message_id, which exact counts need. The timestamp filter keeps FINAL to the weeks the query reads.

-- ClickHouse. Accounts created in September 2026, and how many reached each step within 7 days.
-- FINAL collapses rows that share a message_id (ReplacingMergeTree deduplication).
WITH per_user AS (
    SELECT
        user_id,
        argMinIf(context_campaign_source, timestamp, type = 'page') AS first_touch_source,
        anyIf(kyc_provider, event = 'KYC Submitted') AS kyc_provider,
        minIf(timestamp, event = 'Account Created') AS created_at,
        minIf(timestamp, event = 'KYC Submitted') AS submitted_at,
        minIf(timestamp, event = 'KYC Approved') AS approved_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', 'KYC Submitted', 'KYC Approved', 'Account Funded'))
    GROUP BY user_id
)
SELECT
    if(first_touch_source = '', '(none)', first_touch_source) AS source,
    if(kyc_provider = '', '(no submission)', kyc_provider) AS provider,
    count() AS created,
    countIf(submitted_at BETWEEN created_at AND created_at + INTERVAL 7 DAY) AS kyc_submitted,
    countIf(approved_at BETWEEN created_at AND created_at + INTERVAL 7 DAY) AS kyc_approved,
    countIf(funded_at BETWEEN created_at AND created_at + INTERVAL 7 DAY) AS funded,
    round(funded / created, 3) AS created_to_funded
FROM per_user
WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01'
GROUP BY source, provider
ORDER BY created DESC, source, provider;

Each output row is one first-touch source and KYC provider: accounts created, then how many submitted KYC, were approved and funded within seven days, and the created-to-funded rate. A provider with many submissions and few approvals points at document quality or that vendor's rules. Few submissions point at the screen before verification.

The kyc_contract_violations table shows when the funnel is wrong because an event is malformed rather than because users stalled.

Example implementation (illustrative): a neobank with two KYC vendors

Illustrative example: not a Jitsu customer.

A neobank onboards customers in two markets with a different KYC vendor in each. The web signup and the iOS and Android apps send Signup Started, KYC Started and KYC Submitted. Its backend sends Account Created, both vendors' decisions from their webhooks and Deposit Completed, and fires Account Funded once when a user's first deposit settles.

Every event goes to ClickHouse with the guardrail and the contract function on the connection, and product events also go to Amplitude.

Suppose the September query returns similar submission counts for both vendors and fewer approvals for one of them. Its data team reads that vendor's KYC Rejected rows by reason_category first, then checks kyc_contract_violations for rejections with no reason_category, which would mean the webhook handler stopped mapping that vendor's codes. The same Account Funded event becomes the ad conversion on the funded-account conversions page.

Where Jitsu does not fit

  • Stitching is Enterprise on Jitsu Cloud and writes to warehouses only, as above. Teams without it can send the UTM values captured at signup as properties of Account Created.
  • Mobile goes through Segment's open-source libraries; Jitsu has no SDK of its own.
  • Functions run per connection. They do not run on device destinations or on data pulled in by connectors, so a funnel event loaded by a connector skips the contract.
  • Column types come from the first value Jitsu sees. If amount arrives first as 250, the column is an integer and 250.5 does not fit it, so create the column before the first event or set its type with __sql_type_amount (schema docs).
  • Funnel definitions live in your SQL or BI tool. Jitsu stores the events, and the query above is the funnel.

Questions people ask

Which events should come from the backend? Anything that is a financial or compliance fact: account creation, KYC decisions, deposits, withdrawals and the first funded deposit. The browser and the app send the steps a user takes, such as opening the KYC flow.

Is identity stitching on every Jitsu Cloud plan? No. On Jitsu Cloud, identity stitching is part of the Enterprise plan, and self-hosted Jitsu has no plan restriction. It rewrites anonymous events up to 30 days back, in ClickHouse, BigQuery, PostgreSQL, Redshift, Snowflake and MySQL only.

What stops a retried webhook from creating a second row? Set messageId from the vendor's decision ID. Jitsu deduplicates on message_id, and on ClickHouse a query needs FINAL to see one row per message_id.

Where do I get the tracking plan? The fintech tracking plan is a CSV with 35 events, their source, properties and types, PII handling and example destinations. It is linked from this page and is not gated.

Get started with Jitsu

Book a demo to talk through identity stitching on the Enterprise plan or a self-hosted install. To start on Jitsu Cloud, Get started and send the first backend event with a server-to-server write key.

Subscribe to the Jitsu newsletter
Occasional product updates when we ship something worth knowing about. No spam.