Facebook CAPI
Layer 2 destination model for Facebook Conversions API (CAPI). Thin projection from marketing_conversions_canonical with Facebook-native field names, send-log exclusion, and pixel deduplication.
marketing_conversions_facebook is a Layer 2 destination model that projects
marketing_conversions_canonical into the Facebook Conversions API payload
format. It should contain minimal logic -- all business rules and mappings live
in Layer 1.
Column names in this model should match the
Facebook CAPI parameter names
as closely as possible so the table resembles the actual payload. Layer 1 uses a
facebook_ prefix on mapped columns (e.g. facebook_event_name,
facebook_event_id) to distinguish them from nexus-native columns. Layer 2
strips that prefix, producing destination-native names like event_name,
event_id, external_id, etc.
Template
{{ config(materialized='view', tags=['facebook', 'capi', 'outputs']) }}
WITH facebook_projection AS (
SELECT
-- Sync identifiers
conversion_sync_id,
nexus_event_id,
-- Server event parameters
facebook_event_name as event_name,
facebook_event_time as event_time,
facebook_event_id as event_id,
action_source,
'https://www.your-domain.com' as event_source_url,
-- Custom data
value,
currency,
facebook_content_type as content_type,
contents,
-- Customer information parameters
facebook_external_id as external_id,
facebook_subscription_id as subscription_id,
entity_id,
email,
full_name,
phone,
first_name,
last_name,
city,
state,
zip_code,
country,
-- Attribution
fbclid,
fbclid_occurred_at,
fbc,
fbp
FROM {{ ref('marketing_conversions_canonical') }}
WHERE occurred_at >= DATEADD('day', -7, CURRENT_TIMESTAMP())
),
already_sent AS (
SELECT facebook_event_sync_id as conversion_sync_id
FROM {{ ref('nexus_facebook_capi_send_log') }}
WHERE status = 'sent'
)
SELECT fp.*,
CASE
WHEN already_sent.conversion_sync_id IS NOT NULL THEN TRUE
ELSE FALSE
END as already_sent
FROM facebook_projection fp
LEFT JOIN already_sent
ON fp.conversion_sync_id = already_sent.conversion_sync_id
WHERE already_sent.conversion_sync_id IS NULL
Hashing
PII fields (email, phone, first_name, last_name, city, state,
zip_code, country) are sent unhashed from this model. The reverse ETL
tool or delivery adapter is expected to apply SHA-256 hashing per
Facebook's normalization requirements
before sending to the CAPI endpoint. This keeps the dbt model readable and the
data queryable for QA.
external_id is the one exception -- Facebook recommends (but does not require)
hashing it. Since it is already computed as SHA2(LOWER(TRIM(email))) in Layer
1, it arrives pre-hashed.
Design Notes
Why the facebook_ Prefix in Layer 1
Layer 1 (marketing_conversions_canonical) contains columns for multiple
destinations alongside nexus-native columns. Without a prefix, names collide:
both nexus and Facebook have an event_name, both have an event_id, etc.
The facebook_ prefix disambiguates in Layer 1:
Layer 1: event_name = nexus event name (e.g. 'enrollment')
Layer 1: facebook_event_name = Facebook event name (e.g. 'Purchase')
Layer 2 strips the prefix so the output table mirrors the actual CAPI payload:
Layer 2: event_name = 'Purchase' (from facebook_event_name)
Layer 2: event_time = 1710300000 (from facebook_event_time)
Layer 2: event_id = '13189672' (from facebook_event_id)
event_time
Facebook requires event_time as a Unix timestamp in seconds. Layer 1 computes
this from occurred_at:
EXTRACT(EPOCH FROM occurred_at)::BIGINT as facebook_event_time
7-Day Recency Window
Facebook rejects the entire batch if any event has an event_time older
than 7 days. Filter in the projection CTE:
WHERE occurred_at >= DATEADD('day', -7, CURRENT_TIMESTAMP())
Layer 1 retains the full historical record. This filter belongs in Layer 2 only.
event_source_url
Facebook requires event_source_url for events with
action_source = 'website'. Since these are server-side conversions without a
specific page URL, hard-code your client's primary domain:
'https://www.your-domain.com' as event_source_url
This satisfies the requirement and improves event quality scoring.
subscription_id
For subscription-based businesses, subscription_id helps Facebook identify
recurring revenue events. This should be the stable contract identifier that
persists across renewals (e.g. contract_number), not the per-event
source_record_id (which may differ between enrollments and renewals).
Layer 1 maps this as contract_number as facebook_subscription_id.
Pixel Deduplication
Facebook compares browser pixel events and CAPI events using event_id. Both
must send the same value for deduplication to work.
nexus_events.event_id is an internal nexus identifier not available in the
browser, so it cannot be used. Instead, use source_record_id from Layer 1,
which maps to the source system's business record (e.g. contract number, invoice
ID). This value should also be emitted by the browser pixel.
Layer 1: source_record_id --> Layer 2: event_id
Layer 1: nexus_event_id --> Layer 2: nexus_event_id (QA only)
external_id
Facebook uses external_id for two things:
- Browser-to-server matching -- linking a pixel event to a CAPI event for the same session.
- Cross-event user stitching -- linking multiple server-side conversions to the same user, improving attribution, audience building, and optimization.
Because of (2), a consistent non-null value is always better than null, even if
the browser can't reproduce it. Layer 1 computes user_id with a fallback
chain: real user ID > email hash > entity_id. Layer 2 strips the prefix:
facebook_external_id as external_id
When the value is an email hash or real user ID, the front end must apply the
same normalization and hashing so both sides match. When the fallback is
entity_id, browser matching won't work on this field, but cross-event
stitching still benefits.
See the user_id section in the index for the full fallback chain.
fbc Formatting
If your Layer 1 model does not already compute fbc, add it in Layer 1's
marketing_conversions_mapped CTE:
CASE
WHEN fbclid IS NOT NULL AND fbclid_occurred_at IS NOT NULL THEN
'fb.1.' ||
CAST(EXTRACT(EPOCH FROM fbclid_occurred_at) * 1000 AS BIGINT)::VARCHAR ||
'.' ||
fbclid
ELSE NULL
END as fbc
The format is fb.1.<click_timestamp_ms>.<fbclid>. See
Facebook docs.
fbp Placeholder
fbp (Facebook browser ID) is a client-side cookie value. It is not available
in server-side data. Include it as CAST(NULL AS VARCHAR) to keep the table
contract stable. If your delivery tool or pixel can populate it, it improves
match quality.
Send-Log Exclusion
The already_sent CTE prevents re-sending events. This requires a send-log
model (e.g. nexus_facebook_capi_send_log) that records
facebook_event_sync_id and status for each delivered event.
If you don't have a send log yet, omit the join. The model will output all eligible events on every run (idempotent delivery depends on your reverse ETL tool's dedup behavior).
Schema Tests
models:
- name: marketing_conversions_facebook
columns:
- name: event_id
description: >
Source record ID used as Facebook CAPI deduplication event_id. Sourced
from marketing_conversions_canonical.facebook_event_id.
tests:
- not_null
- name: event_name
description: Facebook standard event name (e.g. Purchase, Lead).
tests:
- not_null
- name: event_time
description: Unix timestamp in seconds.
tests:
- not_null
- name: nexus_event_id
description: Internal nexus event ID for reconciliation.
tests:
- not_null