feat(capi): server-side Meta Conversions API for signup (CompleteRegistration)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

The browser pixel under-counts signups badly: ~96% of paid traffic is mobile
in-app browsers where iOS ITP / ad-blockers drop client events, and the OAuth
path never fired it reliably. Meta recorded ~0 registrations for a 7.5K-spend
campaign while PostHog saw 98 facebook signups — so Meta could neither optimize
toward nor attribute signups, which is the main driver of the low signup rate.

This adds a server-side CAPI CompleteRegistration:
- MetaCapiService + @Global module. Fail-open: no-ops unless META_CAPI_PIXEL_ID
  + META_CAPI_ACCESS_TOKEN are set; never throws (signup must not break).
  SHA-256 hashed email + fbp/fbc/IP/UA.
- Fired from the better-auth user.create.after hook for ALL signups (reliable,
  covers Google OAuth which the browser pixel missed entirely).
- A session-gated POST /analytics/meta/complete-registration endpoint adds
  fbp/fbc/IP/UA (ad-click attribution) for the email path.
- The browser pixel now passes a shared event_id (signup_<userId>); the
  premature Google client-pixel fire (fired on click, before completion) is
  removed.
- All sources dedupe via event_id=signup_<userId>.

Activate by setting META_CAPI_PIXEL_ID + META_CAPI_ACCESS_TOKEN (Events Manager)
in the api env; META_CAPI_TEST_EVENT_CODE routes to Test Events for verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 15:19:58 +03:00
parent 3325747428
commit 145689a391
10 changed files with 223 additions and 5 deletions

View File

@@ -31,6 +31,7 @@ import { HealthController } from "./health.controller";
import { EmexModule } from "./integrations/emex/emex.module"; import { EmexModule } from "./integrations/emex/emex.module";
import { InternalAdminModule } from "./internal-admin/internal-admin.module"; import { InternalAdminModule } from "./internal-admin/internal-admin.module";
import { JobsModule } from "./jobs/jobs.module"; import { JobsModule } from "./jobs/jobs.module";
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
import { NotificationsModule } from "./notifications/notifications.module"; import { NotificationsModule } from "./notifications/notifications.module";
import { PartsModule } from "./parts/parts.module"; import { PartsModule } from "./parts/parts.module";
import { PaymentsModule } from "./payments/payments.module"; import { PaymentsModule } from "./payments/payments.module";
@@ -95,6 +96,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
ChatwootModule, ChatwootModule,
BlogModule, BlogModule,
ContactModule, ContactModule,
MetaCapiModule,
PostHogModule, PostHogModule,
TelemetryModule, TelemetryModule,
InternalAdminModule, InternalAdminModule,

View File

@@ -1,6 +1,7 @@
import { Module, type OnModuleInit } from "@nestjs/common"; import { Module, type OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { EmailService } from "../email/email.service"; import { EmailService } from "../email/email.service";
import { MetaCapiService } from "../meta-capi/meta-capi.service";
import { NovuService } from "../notifications/novu.service"; import { NovuService } from "../notifications/novu.service";
import { ReferralsModule } from "../referrals/referrals.module"; import { ReferralsModule } from "../referrals/referrals.module";
import { ReferralsService } from "../referrals/referrals.service"; import { ReferralsService } from "../referrals/referrals.service";
@@ -20,6 +21,7 @@ export class AuthModule implements OnModuleInit {
private emailService: EmailService, private emailService: EmailService,
private novuService: NovuService, private novuService: NovuService,
private referralsService: ReferralsService, private referralsService: ReferralsService,
private metaCapiService: MetaCapiService,
) {} ) {}
onModuleInit() { onModuleInit() {
@@ -36,6 +38,7 @@ export class AuthModule implements OnModuleInit {
emailService: this.emailService, emailService: this.emailService,
novu: this.novuService, novu: this.novuService,
onEmailVerified: (userId) => this.referralsService.qualifyReferral(userId), onEmailVerified: (userId) => this.referralsService.qualifyReferral(userId),
metaCapi: this.metaCapiService,
}); });
} }
} }

View File

@@ -45,6 +45,11 @@ interface AuthOptions {
novu?: NovuService; novu?: NovuService;
/** Called after a user's email is verified (referral qualification, etc.). */ /** Called after a user's email is verified (referral qualification, etc.). */
onEmailVerified?: (userId: string) => Promise<void>; onEmailVerified?: (userId: string) => Promise<void>;
/** Server-side Meta CAPI sender — reliable signup backstop for BOTH email and
* Google OAuth (the browser pixel misses OAuth and is ad-blocked on mobile). */
metaCapi?: {
sendCompleteRegistration: (input: { userId: string; email?: string | null }) => Promise<void>;
};
} }
export function createAuth( export function createAuth(
@@ -155,6 +160,14 @@ export function createAuth(
}; };
}, },
after: async (user) => { after: async (user) => {
// Server-side Meta CAPI signup event — reliable for BOTH email and
// Google OAuth (the browser pixel misses OAuth and is ad-blocked on
// mobile in-app). Deduped with the pixel/endpoint via
// event_id=signup_<id>. Fire-and-forget; never break signup.
void options?.metaCapi?.sendCompleteRegistration({
userId: user.id,
email: user.email,
});
// Fire-and-forget lifecycle e-mails on signup (covers password + // Fire-and-forget lifecycle e-mails on signup (covers password +
// OAuth). Never await / never let a notification failure surface // OAuth). Never await / never let a notification failure surface
// into the signup response. `referral` is delay-stepped in Novu // into the signup response. `referral` is delay-stepped in Novu

View File

@@ -0,0 +1,57 @@
import { Body, Controller, HttpCode, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { MetaCapiService } from "./meta-capi.service";
@Controller("analytics/meta")
export class MetaCapiController {
constructor(private readonly metaCapi: MetaCapiService) {}
/**
* High-match-quality `CompleteRegistration` for the email signup path. Called by
* the client right after a successful signup (session cookie present), so it can
* attach `fbp`/`fbc`/IP/UA that the server-side auth hook can't see — `fbc`
* (from the `fbclid` ad-click) is what ties the signup back to the ad.
*
* Session-gated: the user id/email come from the session, never the client.
* Deduped with the browser pixel + the auth hook via `event_id=signup_<userId>`.
*/
@Post("complete-registration")
@HttpCode(204)
async completeRegistration(
@CurrentUser() user: { id: string; email?: string } | undefined,
@Req() req: Request,
@Body() body: { eventSourceUrl?: string },
): Promise<void> {
if (!user?.id) return;
const cookies = parseCookies(req.headers.cookie);
await this.metaCapi.sendCompleteRegistration({
userId: user.id,
email: user.email,
fbp: cookies._fbp,
fbc: cookies._fbc,
clientIp: clientIp(req),
userAgent: req.headers["user-agent"],
eventSourceUrl: body?.eventSourceUrl,
});
}
}
/** Minimal Cookie-header parser (no cookie-parser middleware in this app). */
function parseCookies(header?: string): Record<string, string> {
const out: Record<string, string> = {};
if (!header) return out;
for (const part of header.split(";")) {
const eq = part.indexOf("=");
if (eq < 0) continue;
const key = part.slice(0, eq).trim();
if (key) out[key] = decodeURIComponent(part.slice(eq + 1).trim());
}
return out;
}
function clientIp(req: Request): string | undefined {
const xff = req.headers["x-forwarded-for"];
if (typeof xff === "string" && xff.length > 0) return xff.split(",")[0]?.trim();
return req.ip;
}

View File

@@ -0,0 +1,13 @@
import { Global, Module } from "@nestjs/common";
import { MetaCapiController } from "./meta-capi.controller";
import { MetaCapiService } from "./meta-capi.service";
// @Global so the auth module's databaseHook can inject MetaCapiService without
// an import cycle.
@Global()
@Module({
controllers: [MetaCapiController],
providers: [MetaCapiService],
exports: [MetaCapiService],
})
export class MetaCapiModule {}

View File

@@ -0,0 +1,103 @@
import { createHash } from "node:crypto";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
const GRAPH_VERSION = "v21.0";
export interface CapiRegistrationInput {
userId: string;
email?: string | null;
/** _fbp cookie (browser pixel id). */
fbp?: string;
/** _fbc cookie (derived from the fbclid ad-click param). Key for ad attribution. */
fbc?: string;
clientIp?: string;
userAgent?: string;
eventSourceUrl?: string;
}
/**
* Meta Conversions API — server-side signup tracking.
*
* The browser pixel alone under-counts: ~96% of paid traffic is mobile in-app
* browsers where iOS ITP / ad-blockers drop client events, and the OAuth path
* never fires it reliably. This sends `CompleteRegistration` server-side so Meta
* can actually optimize toward (and attribute) signups.
*
* Fail-open: no-ops unless both pixel id and access token are configured, and
* never throws — signup must never break because of analytics.
*/
@Injectable()
export class MetaCapiService {
private readonly logger = new Logger(MetaCapiService.name);
private readonly pixelId?: string;
private readonly accessToken?: string;
private readonly testEventCode?: string;
private readonly enabled: boolean;
constructor(private configService: ConfigService) {
this.pixelId = this.configService.get<string>("META_CAPI_PIXEL_ID") || undefined;
this.accessToken = this.configService.get<string>("META_CAPI_ACCESS_TOKEN") || undefined;
this.testEventCode = this.configService.get<string>("META_CAPI_TEST_EVENT_CODE") || undefined;
this.enabled = Boolean(this.pixelId && this.accessToken);
if (!this.enabled) {
this.logger.warn(
"Meta CAPI disabled — set META_CAPI_PIXEL_ID + META_CAPI_ACCESS_TOKEN to enable",
);
}
}
/**
* Send a server-side `CompleteRegistration`. The deterministic
* `event_id = signup_<userId>` deduplicates against the browser pixel and any
* other CAPI source (e.g. the auth hook) for the same user, so multiple
* redundant sends collapse to one event with the best merged signals.
*/
async sendCompleteRegistration(input: CapiRegistrationInput): Promise<void> {
if (!this.enabled) return;
try {
const userData: Record<string, unknown> = {};
if (input.email) userData.em = [sha256(input.email.trim().toLowerCase())];
if (input.fbp) userData.fbp = input.fbp;
if (input.fbc) userData.fbc = input.fbc;
if (input.clientIp) userData.client_ip_address = input.clientIp;
if (input.userAgent) userData.client_user_agent = input.userAgent;
const body = {
data: [
{
event_name: "CompleteRegistration",
event_time: Math.floor(Date.now() / 1000),
event_id: `signup_${input.userId}`,
action_source: "website",
...(input.eventSourceUrl ? { event_source_url: input.eventSourceUrl } : {}),
user_data: userData,
},
],
...(this.testEventCode ? { test_event_code: this.testEventCode } : {}),
};
const res = await fetch(
`https://graph.facebook.com/${GRAPH_VERSION}/${this.pixelId}/events?access_token=${this.accessToken}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(5000),
},
);
if (!res.ok) {
const text = await res.text().catch(() => "");
this.logger.warn(
`Meta CAPI CompleteRegistration failed (${res.status}): ${text.slice(0, 300)}`,
);
}
} catch (err) {
this.logger.warn(`Meta CAPI CompleteRegistration error: ${(err as Error).message}`);
}
}
}
function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
}

View File

@@ -24,7 +24,7 @@ function ensureStub(): Fbq | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
if (window.fbq) return window.fbq; if (window.fbq) return window.fbq;
const n: Fbq = function (...args: FbqArgs) { const n: Fbq = (...args: FbqArgs) => {
if (n.callMethod) { if (n.callMethod) {
n.callMethod(...args); n.callMethod(...args);
} else { } else {
@@ -63,7 +63,9 @@ export function trackPageView(): void {
window.fbq?.("track", "PageView"); window.fbq?.("track", "PageView");
} }
export function track(event: string, params?: Record<string, unknown>): void { export function track(event: string, params?: Record<string, unknown>, eventId?: string): void {
if (!_initialized) return; if (!_initialized) return;
window.fbq?.("track", event, params); // `eventID` lets Meta dedupe this browser event against the server-side CAPI
// event for the same action — both must use the same id.
window.fbq?.("track", event, params, eventId ? { eventID: eventId } : undefined);
} }

View File

@@ -150,7 +150,13 @@ function RegisterPage() {
// onboarding modal on the search page is the single place that applies it // onboarding modal on the search page is the single place that applies it
// (covers both email and Google OAuth signups). // (covers both email and Google OAuth signups).
capture("user_signed_up", { method: "email" }); capture("user_signed_up", { method: "email" });
trackMeta("CompleteRegistration", { method: "email" }); // CompleteRegistration: browser pixel + server-side CAPI (adds fbp/fbc/IP/UA
// for ad-click attribution), deduped via the shared event id `signup_<userId>`.
const metaEventId = data?.user ? `signup_${data.user.id}` : undefined;
trackMeta("CompleteRegistration", { method: "email" }, metaEventId);
api
.post("/analytics/meta/complete-registration", { eventSourceUrl: window.location.href })
.catch(() => {});
if (plan) localStorage.setItem(PENDING_PLAN_KEY, plan); if (plan) localStorage.setItem(PENDING_PLAN_KEY, plan);
toast.success("Hesap oluşturuldu!"); toast.success("Hesap oluşturuldu!");
window.location.href = redirectUrl; window.location.href = redirectUrl;
@@ -237,7 +243,10 @@ function RegisterPage() {
onClick={() => { onClick={() => {
startAction("register", { method: "google" }); startAction("register", { method: "google" });
capture("user_signed_up", { method: "google" }); capture("user_signed_up", { method: "google" });
trackMeta("CompleteRegistration", { method: "google" }); // Meta CompleteRegistration for Google is sent SERVER-SIDE on actual
// account creation (auth databaseHook). The browser pixel here fired on
// click (before the user even finished Google auth) and would double-
// count, so it's intentionally not sent client-side for OAuth.
if (plan) localStorage.setItem(PENDING_PLAN_KEY, plan); if (plan) localStorage.setItem(PENDING_PLAN_KEY, plan);
signIn.social({ provider: "google", callbackURL: redirectUrl }); signIn.social({ provider: "google", callbackURL: redirectUrl });
}} }}

View File

@@ -74,6 +74,10 @@ services:
# "false" on non-prod → evaluate flags but DON'T emit analytics to prod project. # "false" on non-prod → evaluate flags but DON'T emit analytics to prod project.
- POSTHOG_CAPTURE_ENABLED=${POSTHOG_CAPTURE_ENABLED:-} - POSTHOG_CAPTURE_ENABLED=${POSTHOG_CAPTURE_ENABLED:-}
- POSTHOG_HOST=${POSTHOG_HOST:-https://eu.i.posthog.com} - POSTHOG_HOST=${POSTHOG_HOST:-https://eu.i.posthog.com}
# Meta Conversions API — server-side signup tracking. Empty → CAPI no-ops.
- META_CAPI_PIXEL_ID=${META_CAPI_PIXEL_ID:-}
- META_CAPI_ACCESS_TOKEN=${META_CAPI_ACCESS_TOKEN:-}
- META_CAPI_TEST_EVENT_CODE=${META_CAPI_TEST_EVENT_CODE:-}
- OTEL_ENABLED=${OTEL_ENABLED:-false} - OTEL_ENABLED=${OTEL_ENABLED:-false}
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-} - OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
- OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS:-} - OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS:-}
@@ -162,6 +166,10 @@ services:
# "false" on non-prod → evaluate flags but DON'T emit analytics to prod project. # "false" on non-prod → evaluate flags but DON'T emit analytics to prod project.
- POSTHOG_CAPTURE_ENABLED=${POSTHOG_CAPTURE_ENABLED:-} - POSTHOG_CAPTURE_ENABLED=${POSTHOG_CAPTURE_ENABLED:-}
- POSTHOG_HOST=${POSTHOG_HOST:-https://eu.i.posthog.com} - POSTHOG_HOST=${POSTHOG_HOST:-https://eu.i.posthog.com}
# Meta Conversions API — server-side signup tracking. Empty → CAPI no-ops.
- META_CAPI_PIXEL_ID=${META_CAPI_PIXEL_ID:-}
- META_CAPI_ACCESS_TOKEN=${META_CAPI_ACCESS_TOKEN:-}
- META_CAPI_TEST_EVENT_CODE=${META_CAPI_TEST_EVENT_CODE:-}
- OTEL_ENABLED=${OTEL_ENABLED:-false} - OTEL_ENABLED=${OTEL_ENABLED:-false}
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-} - OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
- OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS:-} - OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS:-}

View File

@@ -109,6 +109,14 @@ export const envSchema = z.object({
// set "false" on dev. (Plain string — read as `!== "false"`.) // set "false" on dev. (Plain string — read as `!== "false"`.)
POSTHOG_CAPTURE_ENABLED: z.string().optional(), POSTHOG_CAPTURE_ENABLED: z.string().optional(),
// Meta Conversions API — server-side signup tracking (CompleteRegistration) so
// Meta can optimize toward / attribute signups despite the unreliable browser
// pixel. Empty → CAPI no-ops. Pixel id is public; the access token is a secret
// from Events Manager. Test event code routes to Events Manager > Test Events.
META_CAPI_PIXEL_ID: z.string().optional(),
META_CAPI_ACCESS_TOKEN: z.string().optional(),
META_CAPI_TEST_EVENT_CODE: z.string().optional(),
// Sentry — error tracking // Sentry — error tracking
SENTRY_DSN: z.string().url().optional(), SENTRY_DSN: z.string().url().optional(),