Module 8
Meta CAPI: server-side conversion from a Next.js route handler
Send a Meta Conversions API event from a Next.js route handler. Includes SHA-256 hashing of user data and the event_id for browser-Pixel deduplication.
What this does
The Meta Conversions API (CAPI) lets you send conversion events directly from your server to Meta, improving signal quality for campaigns. Combined with the browser Pixel, you get full-funnel coverage even when browser events are blocked.
This implementation hashes user data with SHA-256 before sending (required by Meta) and includes an event_id to deduplicate against the browser Pixel.
SHA-256 helper
// lib/hash.ts
export async function sha256(value: string): Promise<string> {
const normalised = value.trim().toLowerCase();
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(normalised));
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}The route handler
// app/api/meta-capi/route.ts
import { sha256 } from '@/lib/hash';
const DATASET_ID = process.env.META_DATASET_ID;
const ACCESS_TOKEN = process.env.META_CAPI_ACCESS_TOKEN;
interface CAPIRequest {
eventName: string;
eventId: string; // shared with browser Pixel for dedup
sourceUrl: string;
email?: string;
firstName?: string;
phone?: string;
customData?: Record<string, unknown>;
}
export async function POST(request: Request) {
if (!DATASET_ID || !ACCESS_TOKEN) {
return Response.json({ ok: true, sent: false });
}
const body = (await request.json()) as CAPIRequest;
// Hash all user-provided data fields
const userData: Record<string, string> = {};
if (body.email) userData.em = await sha256(body.email);
if (body.firstName) userData.fn = await sha256(body.firstName);
if (body.phone) userData.ph = await sha256(body.phone);
const payload = {
data: [
{
event_name: body.eventName,
event_id: body.eventId,
event_time: Math.floor(Date.now() / 1000),
action_source: 'website',
event_source_url: body.sourceUrl,
user_data: userData,
custom_data: body.customData,
},
],
};
try {
const res = await fetch(
`https://graph.facebook.com/v19.0/${DATASET_ID}/events` + `?access_token=${ACCESS_TOKEN}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}
);
const data = (await res.json()) as { events_received?: number };
return Response.json({ ok: true, sent: true, received: data.events_received });
} catch (err) {
console.error('[meta-capi] send failed:', err);
return Response.json({ ok: true, sent: false }, { status: 500 });
}
}Required environment variables
# .env.local
META_DATASET_ID=123456789 # Your Meta dataset (pixel) ID
META_CAPI_ACCESS_TOKEN=EAA... # System user access tokenConsent guard
Check consent before sending - Meta CAPI events are ad-related and require ad_storage to be granted:
const cookieStore = await cookies();
const raw = cookieStore.get('al_consent')?.value;
const params = new URLSearchParams(decodeURIComponent(raw ?? ''));
if (params.get('ad') !== '1') {
return Response.json({ ok: true, sent: false, reason: 'consent_denied' });
}Test mode
During development, add test_event_code to the payload to route hits to Meta Events Manager Test Events tab without affecting real data:
const payload = {
data: [...],
test_event_code: 'TEST12345', // Remove in production
};See Meta CAPI deduplication via shared event_id for the complete browser + server deduplication pattern.
The free Meta CAPI Base Container includes a GTM container for the browser Pixel side of this setup.
Free container for this module
Meta Conversions API Base Container
GTM container for Meta CAPI with browser-Pixel deduplication via shared event_id. Includes the server-side tag, user data variables, and the deduplication strategy that prevents double-counting in Meta Events Manager.
Get the container →