Meta Pixel + Conversions API — Dokumentasi & Alur
Dokumen ini menjelaskan bagaimana tracking di semogabisa.com bekerja: Meta Pixel di browser + Conversions API (CAPI) di server, dengan deduplikasi via event_id.
1. Latar belakang & tujuan
Kenapa butuh dua jalur (Pixel + CAPI)?
Browser-only Pixel sudah lama menjadi standar untuk tracking ke Meta. Tapi sejak iOS 14.5 (ATT) dan tumbuhnya ad blocker, 30–50% event dari browser hilang sebelum sampai ke Meta.
Conversions API mengirim event yang sama dari server kita langsung ke Meta — server-to-server, tidak bisa di-block. Ini cara Meta meng-handle privacy era pasca-cookies.
Hasil: dua jalur paralel (browser + server) untuk event yang sama, dengan ID unik supaya Meta tidak hitung dua kali.
Goal yang dicapai
| Goal | Solusi di project ini |
|---|---|
| Track registration & deposit accurately | Event CompleteRegistration + Purchase fired setelah backend confirm |
| Reduce lost tracking from iOS/ad blockers | Server CAPI sebagai fallback untuk Pixel yang ke-block |
| Avoid duplicate events | Same event_id di browser + server → Meta dedup dalam ~3 hari |
2. Arsitektur
Sistem tracking kita berjalan di 3 tempat:
Tempat 1 — Browser user
Saat halaman load, ada dua hal yang aktif di browser:
- Meta Pixel (
fbevents.js) — script dari Meta yang di-inject di<head>. Tugas: set cookie_fbp, terimafbq("track", ...)calls, kirim kefacebook.net/tr. - Kode aplikasi kita (React + TrackingProvider) — yang memanggil
fbq()dan mengirim ke server kita sendiri.
Saat event dipicu (misal user klik daftar), kode kita melakukan dua hal sekaligus:
- Panggil
fbq("track", "Purchase", data, { eventID })— Pixel kirim ke Meta dari browser - POST ke
/api/capidengan body{ event_id, email, phone, ... }— endpoint kita sendiri
Tempat 2 — Server Next.js kita (/api/capi)
Server kita menerima request dari step 2 di atas, lalu:
- SHA-256 hash semua PII (email, phone, username)
- Baca IP user dari header
x-forwarded-for - Baca user agent dari header
user-agent - Build payload final sesuai spec Meta CAPI
- POST ke
graph.facebook.com/v23.0/{PIXEL_ID}/eventsdenganaccess_token
Tempat 3 — Meta Graph API
Meta menerima dua event untuk action yang sama: satu dari browser Pixel, satu dari server kita. Karena keduanya pakai event_id yang persis sama, Meta:
- Dedup — hitung sebagai 1 event (bukan 2)
- Match user — cocokkan event ke user Facebook via
fbp,fbc, hashed email/phone, IP, user agent - Attribute — kalau user ini sebelumnya klik ad, event di-attribute ke campaign tersebut
Kenapa pakai 2 jalur?
Browser Pixel bisa di-block oleh:
- iOS 14.5+ App Tracking Transparency
- Ad blocker (uBlock, Brave shields, dll)
- Privacy mode browser
- Network firewall
Server-to-server (CAPI) tidak bisa di-block karena tidak lewat browser user. Jadi kalau Pixel di-block, server CAPI tetap kirim. Kalau Pixel tidak di-block, dua jalur kirim → Meta dedup → tetap 1 event.
Komponen utama
| Layer | File | Tugas |
|---|---|---|
| Browser Pixel init | app/layout.tsx | Inject fbevents.js + fbq('init', PIXEL_ID) |
| Client tracking | contexts/tracking.tsx | Generate event_id, fire fbq(), POST /api/capi |
| Server CAPI | app/api/capi/route.ts | Hash PII, append IP/UA, forward ke Meta |
| Debug console | components/tracking-console.tsx | Visual debugger FAB di kiri-bawah |
| PageView tracker | components/page-view-tracker.tsx | Auto-fire PageView setiap route change |
3. Asal usul setiap field — dari mana data datang?
Setiap field di payload yang dikirim ke Meta berasal dari source berbeda. Beberapa hardcoded, beberapa dari state user, beberapa dari cookie, beberapa dari HTTP header. Section ini mentrace setiap field.
3.1 Top-level event fields
Kolom Required? mengikuti spec resmi Meta CAPI. "Required" = Meta tolak event tanpa field ini. "Recommended" = Meta terima tapi match quality / attribution rusak. "Optional" = aman dikosongkan.
| Field | Required? | Source | Tipe | Lokasi di code |
|---|---|---|---|---|
event_name | Required | Hardcoded string di call site | Literal | Setiap pemanggilan track(), contoh "Purchase" |
event_time | Required | Unix timestamp saat event fire (client time, dalam detik) | Number | tracking.tsx:67 — Math.floor(Date.now() / 1000). Server fallback Date.now()/1000 di route.ts:62 kalau client tidak kirim |
action_source | Required | Hardcoded "website" | Literal | route.ts:64 |
event_id | Recommended (Required untuk dedup) | Generated random saat track() dipanggil | UUID-like (evt_xxx) | tracking.tsx:66 — uuid(). Server reject 400 kalau missing di route.ts:45-49 karena tanpa ini sistem dedup kita pecah |
event_source_url | Required (karena action_source: website) | window.location.href saat fire, fallback header referer di server | URL string | tracking.tsx:95-97, fallback route.ts:65-66 |
user_data | Required (object harus ada, dengan minimal 1 identifier) | Lihat section 3.2 | Object | route.ts:67-77 |
custom_data | Optional di top-level, Required value+currency untuk event Purchase | Lihat section 3.3 | Object | route.ts:78 |
test_event_code | Optional (hanya saat testing) | Env META_TEST_EVENT_CODE | String | route.ts:81 |
End-to-end trace untuk Purchase:
// ─── Step 1: call site (app/(app)/deposit/page.tsx) ───
const eventId = track({
event_name: "Purchase", // ← hardcoded literal
custom_data: { value: amount, ... },
user_data: { email, phone, external_id },
});
// ─── Step 2: di contexts/tracking.tsx ───
const event_id = uuid(); // ← "evt_a3f5b9e2..." generated di sini
const event_time = Math.floor(Date.now() / 1000); // ← 1716033600
window.fbq("track", "Purchase", custom_data, { eventID: event_id }); // browser
fetch("/api/capi", {
body: JSON.stringify({ event_name, event_id, event_time, ... })
});
// ─── Step 3: di app/api/capi/route.ts ───
{
data: [{
event_name, // ← "Purchase" passthrough
event_time, // ← 1716033600 (client time, atau server fallback)
event_id, // ← "evt_a3f5b9e2..." passthrough
action_source: "website", // ← hardcoded di server
event_source_url, // ← "http://localhost:3000/deposit"
...
}]
}
3.2 user_data fields — 3 kategori source
Aturan dasar dari Meta: object
user_dataitu sendiri Required, dan minimal harus ada 1 identifier di dalamnya. Identifier yang valid termasuk salah satu dari:em,ph,external_id,client_ip_address+client_user_agent(pair),fbp,fbc. Event ditolak kalau tidak ada satupun. Karena itu setiap field di bawah technically "Optional" sendiri-sendiri, tapi secara kolektif minimal 1 wajib ada.
A. Diisi dari state user — di-hash SHA-256 di server
| Field di Meta | Required? | Source di state | Diteruskan sebagai | Server hash | Contoh transformasi |
|---|---|---|---|---|---|
em | Optional (recommended — match quality tertinggi) | user.email di contexts/user.tsx | user_data.email (plaintext) di POST body | route.ts:68 | [email protected] → ["a3f5b9..."] |
ph | Optional (recommended) | user.phone | user_data.phone | route.ts:69 | +62 812 3456 7890 → ["7b8c9d..."] |
external_id | Optional (recommended) | user.username | user_data.external_id | route.ts:70-72 | andidewi → ["1f2e3d..."] |
State user email/phone/username diisi pada:
| Trigger | Lokasi | Bagaimana |
|---|---|---|
| Form register submit | register/page.tsx:60-65 | setUser({ username, email, phone, balance }) dengan input dari form |
| Login (saat ini mock) | user.tsx:23-28 | Default user andidewi digunakan |
| Edit di Profile page | profile/page.tsx | Saat user save (belum wired ke setUser, hanya local state) |
B. Diambil dari cookie atau URL — tidak di-hash
| Field | Required? | Source | Cara kerja |
|---|---|---|---|
fbp | Optional (recommended — perlu untuk attribution organik) | Cookie _fbp yang di-set oleh fbevents.js saat Pixel init | tracking.tsx:44-46 — readCookie("_fbp"). Kalau belum ada (Pixel belum sempat init), pakai placeholder fb.1.{ts}.{random} |
fbc | Optional (Required hanya kalau user datang dari ad click) | Cookie _fbc yang di-set Pixel dari ?fbclid= URL param, atau di-construct manual | tracking.tsx:47-54 — baca cookie real dulu, kalau tidak ada cek ?fbclid= di URL dan construct |
Format fbp dan fbc:
_fbp = fb.1.1716033600123.1234567890
│ │ │ └─ random ID (10 digit)
│ │ └─ Unix timestamp dalam millisecond
│ └─ subdomain index (selalu 1)
└─ version (selalu fb)
_fbc = fb.1.1716033600123.IwAR1abc...
└─ value dari ?fbclid= di URL
Kapan fbc ada? Hanya kalau user datang dari ad click (URL mengandung ?fbclid=...). Visitor organik = fbc kosong, normal.
C. Diambil dari HTTP request header — tidak di-hash
| Field | Required? | Source header | Fallback | Lokasi di code |
|---|---|---|---|---|
client_ip_address | Optional sendiri, tapi Required ketika tidak ada em/ph/external_id (pair dengan client_user_agent) | x-forwarded-for (first IP dalam comma-list) | x-real-ip | route.ts:52-55 |
client_user_agent | Optional sendiri, tapi Required ketika tidak ada em/ph/external_id (pair dengan client_ip_address) | user-agent | — | route.ts:56 |
Di production behind Vercel/Cloudflare, kedua header ini di-set otomatis oleh edge proxy. Di localhost biasanya IP = ::1 (IPv6 loopback) — itu normal, event tetap delivered tapi tidak bisa match siapa pun.
3.3 custom_data fields — per-event, dari call site
Tidak ada hashing, tidak ada transformasi server. Langsung passthrough dari call site ke Meta. Required/Optional di kolom Required? mengacu ke Meta Pixel standard events reference.
| Event | custom_data field | Required? | Source value |
|---|---|---|---|
| PageView | content_name | Optional (Meta PageView tidak punya required custom_data) | pathname dari usePathname() di page-view-tracker.tsx:21 |
| ViewContent | content_ids | Optional (Required untuk Advantage+ catalog ads) | [game.id] — dari object game yang diklik |
content_name | Optional | game.name — "Lucky Sevens" | |
content_category | Optional | game.category — "Slot" | |
content_type | Optional | Hardcoded "game" | |
| CompleteRegistration | content_name | Optional | Hardcoded "signup" |
status | Optional | Hardcoded "completed" | |
currency | Optional | Hardcoded "IDR" | |
value | Optional | Hardcoded 0 (registrasi gratis) | |
| Purchase | value | Required | amount — angka dari input form deposit |
currency | Required | Hardcoded "IDR" | |
content_name | Optional | Hardcoded "deposit" | |
content_type | Optional | Hardcoded "deposit" | |
order_id | Optional (recommended — untuk reporting & dedup di sisi reporting) | txId generated saat deposit confirmed: "TX" + Date.now().toString(36).toUpperCase() |
Catatan untuk Purchase: Meta akan menolak event Purchase yang tidak punya
valuedancurrency. Karena di code deposit/page.tsx keduanya selalu di-set dari amount input + hardcoded"IDR", requirement ini selalu terpenuhi. Jangan hapus salah satunya tanpa update logic-nya juga.
3.4 Bagaimana semua data digabung jadi satu payload
Data datang dari 3 sumber, lalu di-gabungkan jadi payload final. Berikut alurnya step-by-step saat user trigger event (misal klik "Bayar Sekarang"):
Step 1 — User klik tombol di browser.
Handler di page component (misal deposit/page.tsx) memanggil track({ event_name, custom_data, user_data }). Call site sudah punya:
event_name(hardcoded di kode, contoh"Purchase")custom_data.value,currency,order_id(sebagian hardcoded,valuedari input form)user_data.email,phone,external_id(dari state user di context — diisi saat register)
Step 2 — Function track() menambahkan field runtime.
Di contexts/tracking.tsx, saat track() dipanggil, dia menambahkan:
event_id = uuid()— UUID baru per call (ini yang dipakai untuk dedup)event_time = Math.floor(Date.now() / 1000)— timestamp sekarangevent_source_url = window.location.href— URL halaman saat event firefbp— baca dari cookie_fbp(di-set oleh Meta Pixel)fbc— baca dari cookie_fbcatau dari?fbclid=query param
Step 3 — Dua jalur paralel.
Jalur browser:
fbq("track", event_name, custom_data, { eventID: event_id })— Meta Pixel kirim langsung kefacebook.net/tr. Tidak ada PII di sini.
Jalur server:
fetch("/api/capi", { body: JSON.stringify({ event_name, event_id, event_time, custom_data, user_data, fbp, fbc, ... }) })- PII di body ini masih plaintext — akan di-hash di server next step.
Step 4 — Server /api/capi melengkapi payload.
Di route.ts, server menambahkan field yang hanya bisa dilakukan di server:
- SHA-256 hash
email→em,phone→ph,username→external_id - Baca
x-forwarded-forheader →client_ip_address - Baca
user-agentheader →client_user_agent - Hardcode
action_source: "website" - Append
test_event_codekalau envMETA_TEST_EVENT_CODEada
Step 5 — POST ke Meta.
Server kirim ke https://graph.facebook.com/v23.0/{PIXEL_ID}/events?access_token=... dengan payload lengkap.
3.5 Ringkasan: setiap layer menyumbang apa
| Layer | Contribute apa |
|---|---|
| Call site (page components) | event_name, custom_data, state user (email/phone/username), kondisi business (amount, txId, pathname, game info) |
track() function (tracking.tsx) | event_id (uuid baru), event_time (sekarang), event_source_url (dari location.href), fbp/fbc (dari cookie) |
| Server route handler (route.ts) | SHA-256 hashing PII, baca IP/UA dari request header, hardcode action_source, append test_event_code kalau ada, default event_time kalau missing |
| Meta side | Match user via fbp/fbc/em/ph/external_id, dedup via event_id, attribute ke ad campaign |
4. Alur lengkap satu event (contoh: Purchase)
Skenario: user mau deposit Rp 100.000 via QRIS. Berikut yang terjadi dari klik tombol sampai event terdaftar di Meta.
Step 1 — User klik "Bayar Sekarang"
Di halaman deposit, user pilih nominal Rp 100.000, pilih metode QRIS, klik tombol bayar. Handler confirm() di deposit/page.tsx ke-trigger.
State berubah: setProcessing(true) → tombol berubah jadi "Memproses…".
Step 2 — Tunggu backend confirm payment
Handler await ke payment processor (saat ini di-mock dengan setTimeout 1400ms — di production diganti fetch ke real payment gateway atau menunggu webhook).
Penting: event Meta belum di-fire di step ini. Kalau payment gagal, tidak ada event yang terkirim.
Step 3 — Backend balas sukses, generate txId
Setelah await selesai (artinya payment confirmed), handler generate transaction ID:
const txId = "TX" + Date.now().toString(36).toUpperCase()
// Contoh: "TX1MWXYZ4K"
Step 4 — Panggil track() untuk fire event
const eventId = track({
event_name: "Purchase",
custom_data: {
value: 100000,
currency: "IDR",
content_name: "deposit",
order_id: "TX1MWXYZ4K",
},
user_data: {
email: "[email protected]",
phone: "+62 812 3456 7890",
external_id: "andidewi",
},
});
Step 5 — track() generate event_id dan kirim dua jalur
Di dalam track() (tracking.tsx:62-117):
-
event_id = uuid()→ contohnya"evt_a3f5b9e2...". ID ini dipakai untuk kedua jalur supaya Meta bisa dedup. -
Jalur browser: panggil
fbq("track", "Purchase", { value, currency }, { eventID: event_id }). Meta Pixel di browser langsung kirim request kehttps://www.facebook.com/trdengan parameter event. -
Jalur server:
fetch("/api/capi", { body })dengan body berisi semua data termasuk PII plaintext (email/phone/username).
Kedua jalur berangkat hampir bersamaan dari browser.
Step 6 — Server /api/capi proses request
Di route.ts:
- Parse JSON body dari request
- Hash PII:
sha256("[email protected]")→"a3f5b9..."(64 char hex). Sama untuk phone dan username. - Baca IP user dari header
x-forwarded-for - Baca user-agent dari header
user-agent - Build payload Meta CAPI dengan
action_source: "website",event_time, dan semua field tadi - Kalau env
META_TEST_EVENT_CODEdi-set, append ke payload sebagaitest_event_code
Step 7 — Server POST ke Meta Graph API
POST https://graph.facebook.com/v23.0/1302640427964663/events
?access_token=EAAxxxxx
Content-Type: application/json
{ "data": [{ "event_name": "Purchase", "event_id": "evt_a3f5b9e2...", ... }] }
Meta balas dalam ~200ms:
{ "events_received": 1, "fbtrace_id": "AbCdEf..." }
Step 8 — Server balas client, client log ke debug console
Server return ke browser:
{ "status": "sent", "meta": { "events_received": 1, ... }, "payload": {...} }
Debug console FAB di kiri-bawah aplikasi log entry "server" untuk event ini, di samping entry "browser" yang sudah di-log step 5.
Step 9 — Meta dedup
Di sisi Meta, ada 2 event yang masuk:
- Path browser (lewat
facebook.com/tr) denganevent_id = evt_a3f5b9e2... - Path server (lewat
graph.facebook.com/.../events) denganevent_id = evt_a3f5b9e2...(sama)
Karena ID identik dan masuk dalam window ~3 hari, Meta hitung sebagai 1 Purchase event, bukan 2. Di Test Events tab, ini di-label "Deduplicated".
Step 10 — Redirect ke success page
Di browser, handler confirm() selesai. State update: setSuccessData(...), setProcessing(false), lalu router.push("/success") — user lihat halaman sukses dengan info txId dan eventId.
Catatan penting
- Kalau Pixel di-block (ad blocker / iOS ATT), Path browser gagal tapi Path server tetap kirim. Meta tetap dapat 1 event. Tidak ada dedup karena hanya 1 source, tapi event tetap recorded.
- Kalau server
/api/capierror (token salah, network issue), Path server gagal tapi Path browser tetap kirim. Sebaliknya, Meta tetap dapat 1 event. - Kedua jalur fail di waktu bersamaan sangat jarang — itu yang bikin sistem ini robust.
5. Empat event yang di-track
5.1 PageView — setiap route change
| Property | Value |
|---|---|
| Fired oleh | components/page-view-tracker.tsx:16-25 |
| Trigger | Setiap usePathname() berubah |
| Cara | Auto, tidak ada user action diperlukan |
user_data saat anonymous | fbp, fbc, IP, UA |
user_data saat authenticated | + em, ph, external_id (hashed) |
custom_data | { content_name: pathname } |
// page-view-tracker.tsx
track({
event_name: "PageView",
custom_data: { content_name: pathname },
user_data: authed ? { email, phone, external_id: username } : {},
});
5.2 ViewContent — buka detail game
| Property | Value |
|---|---|
| Fired oleh | app/(app)/lobby/page.tsx:27-44 |
| Trigger | Klik game tile di lobby |
custom_data | { content_ids, content_name, content_category, content_type: "game" } |
// lobby/page.tsx
track({
event_name: "ViewContent",
custom_data: {
content_ids: [game.id],
content_name: game.name,
content_category: game.category,
content_type: "game",
},
user_data: { email, phone, external_id: username },
});
5.3 CompleteRegistration — setelah registrasi sukses
| Property | Value |
|---|---|
| Fired oleh | app/register/page.tsx:42-58 |
| Trigger | Form submit + backend confirmed (saat ini mock dengan setTimeout) |
| Penting | Fire setelah await selesai — bukan saat klik tombol |
custom_data | { content_name: "signup", status: "completed", currency: "IDR", value: 0 } |
// register/page.tsx
setSubmitting(true);
await new Promise((r) => setTimeout(r, 900)); // ← future: real API call to /api/register
// Saat itu sudah server-confirmed
const eventId = track({
event_name: "CompleteRegistration",
custom_data: { content_name: "signup", status: "completed", currency: "IDR", value: 0 },
user_data: { email: form.email, phone: form.phone, external_id: form.username },
});
5.4 Purchase — setelah deposit confirmed
| Property | Value |
|---|---|
| Fired oleh | app/(app)/deposit/page.tsx:23-46 |
| Trigger | Payment processor confirmed (saat ini mock) |
| Penting | Fire setelah await selesai — bukan saat klik bayar |
custom_data | { value, currency: "IDR", content_name: "deposit", order_id } |
// deposit/page.tsx
setProcessing(true);
await new Promise((r) => setTimeout(r, 1400)); // ← future: real call to payment processor webhook
const txId = "TX" + Date.now().toString(36).toUpperCase();
const eventId = track({
event_name: "Purchase",
custom_data: {
value: amount,
currency: "IDR",
content_name: "deposit",
content_type: "deposit",
order_id: txId,
},
user_data: { email: user.email, phone: user.phone, external_id: user.username },
});
6. Mekanisme deduplikasi
Aturan dasar
Same
event_idbetween browser & server within ~3 days → Meta counts it ONCE.
Implementasi di code
// contexts/tracking.tsx:66
const event_id = uuid(); // Satu UUID untuk dua jalur
// Path A — Browser Pixel
window.fbq("track", event_name, custom_data, { eventID: event_id });
// Path B — Server CAPI
fetch("/api/capi", {
method: "POST",
body: JSON.stringify({ event_name, event_id, ... }),
});
Server kemudian forward event_id itu ke Meta sebagai bagian payload (route.ts:65).
Verifikasi dedup berjalan
Di Meta Events Manager → Test Events, klik event yang masuk:
✓ Purchase Browser 12:34:56 Received
✓ Purchase Server 12:34:57 Received · Deduplicated ← label ini = dedup OK
Kalau tidak ada label "Deduplicated" padahal event masuk dari kedua source, berarti event_id-nya tidak sama. Debug di local FAB console — bandingkan event_id dari entry browser vs server.
7. Handling PII (Personally Identifiable Information)
Field yang di-hash (SHA-256, mandatory)
| Field di Meta | Source | Lokasi hashing |
|---|---|---|
em | user.email | route.ts:68 |
ph | user.phone | route.ts:69 |
external_id | user.username | route.ts:70-72 |
Field yang NOT di-hash
| Field | Source |
|---|---|
fbp | Cookie _fbp yang di-set Meta Pixel |
fbc | Cookie _fbc (dari ?fbclid= query param) atau constructed |
client_ip_address | Header x-forwarded-for atau x-real-ip di route.ts:54-55 |
client_user_agent | Header user-agent di route.ts:56 |
Aturan hashing yang benar
// route.ts:20-21
const sha = (s: string) =>
crypto.createHash("sha256").update(s.trim().toLowerCase()).digest("hex");
.trim()— hapus whitespace leading/trailing.toLowerCase()— normalize case ([email protected]dan[email protected]jadi hash yang sama).digest("hex")— output 64-char hex string
Penting: hashing dilakukan di server
Kita tidak hash di browser. Client kirim plaintext email/phone via HTTPS ke /api/capi, server yang hash. Alasan:
- Hash di browser bisa di-tampered (user bisa modify
fbqruntime) - Server-side hashing memastikan konsistensi normalization
- HTTPS sudah secure dari client ke server kita sendiri
Match quality score
Semakin banyak parameter user_data yang lengkap, semakin tinggi match quality score (max 10/10):
| User state | Parameter yang available | Expected score |
|---|---|---|
| Anonymous visitor | fbp, IP, UA, optional fbc | 3-5 |
| Authenticated user | + em, ph, external_id | 8-10 |
8. Environment variables
# .env.local
# Public — exposed ke browser, dipakai di app/layout.tsx untuk init Pixel
NEXT_PUBLIC_META_PIXEL_ID=1302640427964663
# Server-only — dipakai di app/api/capi/route.ts
META_PIXEL_ID=1302640427964663
META_ACCESS_TOKEN=EAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Optional — saat di-set, event di-tag dan hanya muncul di Test Events tab
META_TEST_EVENT_CODE=TEST12345
Behavior tanpa env
Kalau META_PIXEL_ID atau META_ACCESS_TOKEN kosong, route handler return mocked response (route.ts:83-89):
{
"status": "mocked",
"reason": "META_PIXEL_ID or META_ACCESS_TOKEN not configured",
"payload": { /* what would have been sent */ }
}
Berguna untuk development tanpa kredensial real.
9. Testing workflow
A. Setup test environment
- Set semua env di
.env.localtermasukMETA_TEST_EVENT_CODE - Restart dev server:
bun run dev - Buka https://business.facebook.com/events_manager2/list/pixel/1302640427964663/test_events di tab terpisah (= dashboard Test Events Meta)
B. Trigger semua 4 event
Buka http://localhost:3000?fbclid=ABC123TEST (fbclid bikin fbc cookie supaya match quality kelihatan):
| Action | Event yang di-fire |
|---|---|
| Load landing | PageView (anonymous) |
| Klik "Daftar" → submit form | PageView × 1 + CompleteRegistration |
| Klik "Mulai Main" di success | PageView (authenticated) |
| Klik game di lobby | ViewContent |
| Klik "+ Deposit" | PageView |
| Submit deposit form | Purchase |
C. Verifikasi di 3 tempat
1. Local debug console (FAB "Meta Pixel" kiri-bawah app)
- Browser entries muncul instant
- Server entries muncul ~200-500ms kemudian
- Kedua punya
event_idyang identik
2. DevTools Network tab
- Filter
facebook.net/tr— request dari browser Pixel - Filter
/api/capi— request ke server kita - Response
/api/capiharusnya:{ status: "sent", meta: { events_received: 1, ... } }
3. Meta Test Events dashboard
- Event muncul dalam 30-60 detik
- Setiap event punya 2 baris: Browser dan Server, dengan label "Deduplicated" di server entry
- Klik event row → expand "Customer Information Parameters" → harusnya
7/7 detecteduntuk authenticated user
D. Cleanup setelah testing
Hapus META_TEST_EVENT_CODE dari .env.local sebelum deploy production — kalau tidak, event production akan tetap masuk ke Test tab dan tidak muncul di analytics asli.
10. Troubleshooting
Browser Pixel events tidak fire
Cek di Network tab — ada request ke facebook.net/tr?
- ❌ Tidak ada request sama sekali → Pixel script gagal load. Cek
NEXT_PUBLIC_META_PIXEL_IDdi.env.local, restart dev. Atau ad blocker memblokirfbevents.js. - ❌ Request ada tapi
fbq is not defined→ race condition, PageViewTracker fire sebelum script loaded. Browser-side PageView pertama akan miss, tapi server-side via CAPI tetap masuk. Tidak ada action required.
Server CAPI events return error
Cek response /api/capi di Network tab.
| Response | Penyebab | Solusi |
|---|---|---|
status: "mocked" | Env belum di-set | Isi META_PIXEL_ID + META_ACCESS_TOKEN, restart dev |
status: "error", meta: { error: "Invalid OAuth access token" } | Token salah/expired | Generate token baru di Events Manager → Settings → Conversions API |
status: "error", meta: { error: "Unsupported request" } | Pixel ID salah atau token tidak punya akses ke Pixel ini | Verifikasi META_PIXEL_ID sesuai dengan Pixel yang di-assign ke system user |
| Status 502, network error | Connectivity issue | Cek apakah server bisa reach graph.facebook.com |
Dedup tidak nyambung (events muncul tapi tidak ber-label "Deduplicated")
Cek di FAB console — event_id browser dan server harus persis sama untuk event yang sama. Kalau beda:
- Cek tracking.tsx:66 —
const event_id = uuid()di-generate sekali per call - Cek bahwa
event_iddi-pass kefbqsebagaieventID(case-sensitive) dan ke/api/capibody sebagaievent_id
fbp / fbc empty di Test Events
fbpempty → Pixel belum sempat set cookie. Refresh page, retry.fbcempty → User tidak punya?fbclid=di URL. Normal untuk visitor organik. Pastikan ad campaign URL pakai{{fbclid}}macro.
Match quality rendah (<6 untuk authenticated user)
Cek parameter yang missing di Test Events → expand event:
| Parameter missing | Penyebab |
|---|---|
em / ph | User tidak punya email/phone di state, atau form belum di-submit dengan data lengkap |
external_id | Username kosong |
client_ip_address | Behind proxy yang tidak set x-forwarded-for — common di local dev (IP = ::1), normal |
fbp | Pixel belum init saat event fire |
11. Production checklist
Sebelum deploy live:
- HTTPS wajib —
fbq()tidak fire dihttp://non-localhost. Pastikan deploy ke hosting auto-HTTPS (Vercel, Netlify, Cloudflare). - Hapus
META_TEST_EVENT_CODEdari env production. Set hanya di staging/dev. - Domain Verification di Business Settings → Brand Safety → Domains → add domain production. Wajib untuk attribution iOS 14.5+.
- Aggregated Event Measurement (AEM) di Events Manager → configure prioritas 8 event untuk domain. Untuk casino/game site, biasanya:
Purchase > CompleteRegistration > ViewContent > PageView. - Replace mock backend calls dengan real API:
- Register flow → real
/api/auth/registerendpoint - Deposit flow → real payment processor webhook
track()tetap fire setelah real API confirm success (pattern sudah benar)
- Register flow → real
- Rate limiting di
/api/capiroute (opsional) — proteksi dari abuse jika endpoint exposed - Logging & monitoring — log CAPI errors ke observability tool (Sentry, Datadog, dll)
- Token rotation — Meta token never expires, tapi best practice rotate setiap 90 hari. Set reminder di calendar.
- Privacy policy — pastikan halaman privacy mention Meta Pixel dan CAPI sebagai data processor
12. Referensi
- Meta CAPI Get Started: https://developers.facebook.com/docs/marketing-api/conversions-api/get-started
- Required parameters: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters
- Event reference: https://developers.facebook.com/docs/meta-pixel/reference
- Deduplication guide: https://developers.facebook.com/docs/marketing-api/conversions-api/deduplicate-pixel-and-server-events
Last updated: 2026-06-04