New to Kestra?
Use blueprints to kickstart your first workflows.
Send CRM lead stages from a Postgres outbox to Meta Conversions API as Conversion Leads events, with hashing and batch guards.
Sends lead stage changes from your CRM to Meta as Conversion Leads events, so Meta can optimize Lead Ads for the leads that actually move down your funnel instead of every form submission.
The flow reads an outbox table in Postgres. Your CRM, an application trigger or another flow inserts one row per stage change, and this flow ships them in batches every 15 minutes:
check_inputs stops the run if lead_event_source is empty, since Meta requires it.prepare_batch runs one SQL statement that:@, a phone outside 8 to 15 digits, a lead_id outside 15 to 17 digits, and a
value that is negative, NaN, infinite or without a valid ISO 4217 currency;expired the rows older than 7 days minus a one-hour margin, and as invalid
the rows left without a stage name or without any customer identifier. Meta rejects the
entire batch if a single event breaks one of these rules, so one bad row would
otherwise block the queue for good;data array for up to 1,000 events, the API limit.has_events skips the API call when there is nothing to send.send_events posts the batch with action_source: system_generated and the two
custom_data fields Meta requires on every Conversion Leads event: event_source: crm
and lead_event_source. Timestamps in the future are capped at the current time.mark_sent flags exactly the rows of this batch and stores the fbtrace_id. It is
skipped on test runs, so a row checked in Test events still goes to production later.Each event carries event_id = crm-<row id>. If a run fails after Meta accepted the batch
(the database is unreachable for mark_sent), the next run sends the same events again with
the same event_id; keep that in mind when reconciling counts. The flow-level concurrency
limit queues a manual run behind a running one, so the same batch is never sent twice in
parallel.
CREATE TABLE meta_capi_outbox (
id bigserial PRIMARY KEY,
event_name text NOT NULL, -- your CRM stage, e.g. 'Qualified', 'Won'
event_time timestamptz NOT NULL, -- when the lead entered the stage
lead_id bigint, -- Meta Lead Ads lead ID, sent unhashed
email text,
phone text, -- with country code, any format
value numeric,
currency text, -- ISO 4217, e.g. 'USD'
status text NOT NULL DEFAULT 'pending', -- pending | sent | expired | invalid
fbtrace_id text,
sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON meta_capi_outbox (status, event_time);
POSTGRES_URL: JDBC URL, e.g. jdbc:postgresql://host:5432/crmPOSTGRES_USER and POSTGRES_PASSWORD: a user that can read and update meta_capi_outboxMETA_ACCESS_TOKEN: Conversions API access token of the datasetdataset_id (STRING): the Meta dataset ID. Replace the default.lead_event_source (STRING, default In-house CRM): your CRM name, e.g. HubSpot.test_event_code (STRING, optional): run the flow manually with the code from
Events Manager > Test events to check the payload. Sendable rows stay pending on test
runs; expired and invalid rows are still taken out. Leave it empty on the schedule.graph_api_url (STRING, default https://graph.facebook.com/v26.0): pins the Graph API version.INSERT INTO meta_capi_outbox (event_name, event_time, email) VALUES ('Qualified', now(), 'lead@example.com');test_event_code and check the event in Events Manager > Test events.{{ outputs.prepare_batch.row.event_count }}: events in the batch{{ outputs.prepare_batch.row.expired_count }} and {{ outputs.prepare_batch.row.invalid_count }}: rows taken out in this run{{ outputs.send_events.body }}: Meta response with events_received and fbtrace_idmeta_capi_outbox move from pending to sent, expired or invalid