Skip to main content
AnalyticsLearner

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.

meta-capinextjsserver-sideconversions-api

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 token

Consent 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 →