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
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:
@@ -31,6 +31,7 @@ import { HealthController } from "./health.controller";
|
||||
import { EmexModule } from "./integrations/emex/emex.module";
|
||||
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
|
||||
import { JobsModule } from "./jobs/jobs.module";
|
||||
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
|
||||
import { NotificationsModule } from "./notifications/notifications.module";
|
||||
import { PartsModule } from "./parts/parts.module";
|
||||
import { PaymentsModule } from "./payments/payments.module";
|
||||
@@ -95,6 +96,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||
ChatwootModule,
|
||||
BlogModule,
|
||||
ContactModule,
|
||||
MetaCapiModule,
|
||||
PostHogModule,
|
||||
TelemetryModule,
|
||||
InternalAdminModule,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module, type OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { EmailService } from "../email/email.service";
|
||||
import { MetaCapiService } from "../meta-capi/meta-capi.service";
|
||||
import { NovuService } from "../notifications/novu.service";
|
||||
import { ReferralsModule } from "../referrals/referrals.module";
|
||||
import { ReferralsService } from "../referrals/referrals.service";
|
||||
@@ -20,6 +21,7 @@ export class AuthModule implements OnModuleInit {
|
||||
private emailService: EmailService,
|
||||
private novuService: NovuService,
|
||||
private referralsService: ReferralsService,
|
||||
private metaCapiService: MetaCapiService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
@@ -36,6 +38,7 @@ export class AuthModule implements OnModuleInit {
|
||||
emailService: this.emailService,
|
||||
novu: this.novuService,
|
||||
onEmailVerified: (userId) => this.referralsService.qualifyReferral(userId),
|
||||
metaCapi: this.metaCapiService,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@ interface AuthOptions {
|
||||
novu?: NovuService;
|
||||
/** Called after a user's email is verified (referral qualification, etc.). */
|
||||
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(
|
||||
@@ -155,6 +160,14 @@ export function createAuth(
|
||||
};
|
||||
},
|
||||
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 +
|
||||
// OAuth). Never await / never let a notification failure surface
|
||||
// into the signup response. `referral` is delay-stepped in Novu
|
||||
|
||||
57
apps/api/src/meta-capi/meta-capi.controller.ts
Normal file
57
apps/api/src/meta-capi/meta-capi.controller.ts
Normal 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;
|
||||
}
|
||||
13
apps/api/src/meta-capi/meta-capi.module.ts
Normal file
13
apps/api/src/meta-capi/meta-capi.module.ts
Normal 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 {}
|
||||
103
apps/api/src/meta-capi/meta-capi.service.ts
Normal file
103
apps/api/src/meta-capi/meta-capi.service.ts
Normal 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");
|
||||
}
|
||||
@@ -24,7 +24,7 @@ function ensureStub(): Fbq | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
if (window.fbq) return window.fbq;
|
||||
|
||||
const n: Fbq = function (...args: FbqArgs) {
|
||||
const n: Fbq = (...args: FbqArgs) => {
|
||||
if (n.callMethod) {
|
||||
n.callMethod(...args);
|
||||
} else {
|
||||
@@ -63,7 +63,9 @@ export function trackPageView(): void {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -150,7 +150,13 @@ function RegisterPage() {
|
||||
// onboarding modal on the search page is the single place that applies it
|
||||
// (covers both email and Google OAuth signups).
|
||||
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);
|
||||
toast.success("Hesap oluşturuldu!");
|
||||
window.location.href = redirectUrl;
|
||||
@@ -237,7 +243,10 @@ function RegisterPage() {
|
||||
onClick={() => {
|
||||
startAction("register", { 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);
|
||||
signIn.social({ provider: "google", callbackURL: redirectUrl });
|
||||
}}
|
||||
|
||||
@@ -74,6 +74,10 @@ services:
|
||||
# "false" on non-prod → evaluate flags but DON'T emit analytics to prod project.
|
||||
- POSTHOG_CAPTURE_ENABLED=${POSTHOG_CAPTURE_ENABLED:-}
|
||||
- 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_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||
- 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.
|
||||
- POSTHOG_CAPTURE_ENABLED=${POSTHOG_CAPTURE_ENABLED:-}
|
||||
- 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_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||
- OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS:-}
|
||||
|
||||
@@ -109,6 +109,14 @@ export const envSchema = z.object({
|
||||
// set "false" on dev. (Plain string — read as `!== "false"`.)
|
||||
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_DSN: z.string().url().optional(),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user