Conversion Sync
Configure where a profile's conversions are sent, map them to platform events, verify with a Meta test event, then export.
Sending a conversion back to Meta is four calls in a fixed order:
- A destination —
POST /api/v1/exports/destinations. Which Meta dataset (or Google Ads conversion action) this profile ships to. - A rule —
POST /api/v1/exports/rules. Which conversion becomes which platform event. - A test event —
POST /api/v1/exports/test. Prove the round-trip before real data depends on it. - The export —
POST /api/v1/exports. Queue the run, then readGET /api/v1/exports/ledger.
Steps 1 and 2 are both required, and both are silent when missing
POST /api/v1/exports succeeds against a profile with no destination and no
enabled rule, and ships nothing. The export pipeline iterates the rules and
builds one candidate per (rule x destination); with zero of either it builds
zero candidates, for every destination, unconditionally. There is no default
mapping — a profile with conversion definitions but no signal rule exports
nothing, forever, with no error anywhere.
Scopes: exports:read for the reads and the preview, exports:write for
every mutation and for the test send. exports:write is not granted by any
OAuth connect scope — a workspace admin opts a key into it.
POST /api/v1/exports itself stays on attribution:write. Configuring a
destination and transmitting a customer's conversion to it are different acts,
and the second one is the disclosure to a third party.
Step 1 — the destination
POST /api/v1/exports/destinationsScope: exports:write
{
"destination": "meta_capi",
"connection_id": "0d0e6c8a-…",
"meta_pixel_id": "1234567890",
"enabled": true,
"destination_config": {
"signal_quality": { "preset": "balanced" }
}
}| Field | Required | Notes |
|---|---|---|
destination | yes | meta_capi or google_ads. Immutable — a PATCH that changes it is a 422. |
connection_id | yes | A data_connections id on this profile. Its provider must match the kind: meta_capi needs meta_ads. |
meta_pixel_id | for meta_capi | The dataset / pixel id from Events Manager. |
google_conversion_action | for google_ads | customers/{id}/conversionActions/{id}. |
enabled | no | Defaults true. |
destination_config | no | Free-form settings. Deep-merged on PATCH. |
List what already exists with GET /api/v1/exports/destinations; each row
carries a routing verdict (active / disabled /
suppressed_platform_safe) that says whether it will actually fire on the next
run, and a connection.status that is the usual reason a healthy-looking
destination stopped delivering.
PATCH deep-merges destination_config
A partial update that sends only signal_quality keeps the keys it did not
mention — including the data_source block that records which Meta dataset a
messaging destination was wired to. Replacing the object would strand the
destination on a dataset nothing points at.
DELETE /api/v1/exports/destinations/{id} is soft: the row is set
enabled: false and kept, because the deliveries ledger references it and is
the record of every event ever shipped through that dataset. Re-enable with
PATCH … {"enabled": true}.
Step 2 — the rule
POST /api/v1/exports/rulesScope: exports:write
{
"name": "Payment → Purchase",
"source_kind": "conversion_definition",
"source_ref_id": "6f1c…",
"destinations": { "meta_capi": { "enabled": true } },
"platform_event_name_overrides": { "meta_capi": "Purchase" },
"meta_custom_event_type": "PURCHASE",
"value_mode": "event_value",
"priority": 100
}source_ref_id is a conversion_definitions id — the row that says which
outcome_events count as a conversion and whether they carry cash. Get the
list from GET /api/v1/goals/definitions. (outcome_definitions are CRM
pipeline-stage labels and are a different thing entirely.)
value_mode | Sends |
|---|---|
event_value | the conversion's own amount and currency |
fixed_value | source_config.fixed_value + source_config.fixed_currency |
none | no value at all |
value_mode: none on a Purchase is rejected
Meta documents value and currency as required for purchase events, so
such a rule can only ever emit a payload Meta refuses. The route answers 422
naming the event. This is not theoretical: one profile shipped that config for
a month — 152 dead-lettered Purchases, 306 of them permanently unrecoverable,
because Meta's replay window is 7 days.
A pipeline_stage_transition rule maps a CRM stage change instead of a
conversion: put to_stage (or from_stage with trigger_mode: "stage_exited") in source_config, and leave value_mode at none — the
stage sender has nowhere to put an amount.
meta_custom_event_type is Meta's optimization category, and its spellings
are Meta's, not the event names': INITIATED_CHECKOUT (not
INITIATE_CHECKOUT) and CONTENT_VIEW (not VIEW_CONTENT). An unknown value
is a 422 rather than a Postgres error at write time.
Subscription renewals
recurring_payment_mode decides what the rule does with a renewal — a
cash payment_received conversion whose is_first_payment is false.
recurring_payment_mode | What ships |
|---|---|
skip (default) | nothing; the delivery is recorded as skipped with skip_reason: "recurring_payment" |
custom_event | a Meta custom event named SubscriptionRenewal, same value and currency. Google Ads has no custom-event equivalent, so a Google destination skips with recurring_payment_google_no_custom_event |
purchase | the rule's normal event, at full value |
First payments are never affected, under any mode. Neither are refunds
(payment_refunded is also cash with is_first_payment: false) nor any
non-cash conversion.
Every existing rule was backfilled to skip
Meta documents Purchase as a checkout completion and Subscribe as the
START of a paid subscription; it documents nothing for a renewal. Sending
renewals as Purchase teaches the optimizer that the acquiring ad keeps
producing new purchases every month, and Meta credits them to whatever ad the
customer touched since. Meta cannot unsend, so the migration that added this
field set every pre-existing rule to skip. Set purchase explicitly if your
business genuinely wants renewals counted at the platform.
is_first_payment is read from the conversion at evaluation time and is never
recomputed by the exporter — it can legitimately flip later (a backfilled
earlier payment, or the manual override API). A delivery already skipped as a
renewal is not re-sent automatically; replay it like any other skipped row.
Preview before you commit
POST /api/v1/exports/rules/previewScope: exports:read. Writes nothing.
Send the same body a create would take; get back how many source records the rule would have matched over the last 7 days and which Meta event it would emit.
{
"data": {
"days": 7,
"candidate_count": 41,
"matched_on": ["payment_received"],
"platform_event_name": "Purchase"
}
}candidate_count is an upper bound, never a forecast of deliveries: a
matched record still has to clear identity, the privacy guard, the platform's
staleness window and destination routing. A 0 with a non-empty matched_on
means the rule is well-formed and this profile simply had no such events.
Reading rules back
GET /api/v1/exports/rules returns the live (non-archived) rules with
delivery_stats_7d on each. An enabled rule sitting at sent: 0 is the most
common broken state on this surface — usually a disabled destination, a
definition that matches nothing, or a Meta event with no working Custom
Conversion behind it.
DELETE /api/v1/exports/rules/{id} answers 409 invalid_state once the rule
has export history, because the ledger records every event sent under it.
Archive instead — PATCH with archived_at — which has the same effect on the
pipeline.
Step 3 — the test event
POST /api/v1/exports/testScope: exports:write
{ "destination_id": "…", "test_event_code": "TEST12345", "rule_id": "…" }Get test_event_code from Events Manager → Test Events. Without it the event
is a real (if synthetic) event on the dataset rather than a test one.
Everything is optional: omit destination_id and the oldest enabled Meta
destination is used; pass rule_id to borrow that rule's Meta event name and
privacy override, so the test exercises the rule you are actually verifying.
channel: "business_messaging" sends a click-to-WhatsApp shaped event instead
(sample ctwa_clid, messaging_channel: whatsapp).
{
"data": {
"meta_pixel_id": "1234567890",
"event_id": "atribu_signals_test_1757030400000",
"channel": "website",
"action_source": "website",
"filters_applied": [],
"privacy_mode": "standard",
"quality_warning": "This test event was sent with only IP and user agent …",
"meta_response": { "events_received": 1, "fbtrace_id": "Az9…" }
}
}Search event_id in Events Manager → Test Events to confirm arrival.
- The payload carries the same
atribu_srcmarker a production event carries, so the test actually fires the Custom Conversion your rule created. A test that skipped it would fire the pixel and not the conversion — the most misleading possible result while verifying a setup. quality_warningis non-null when this profile's match-quality policy would block a real event carrying only what a test carries (an IP and a user agent). The test still sends — a test that could not run under a strict policy would prove nothing.409 invalid_statemeans the profile's privacy or HIPAA legal gate refused the payload;502means Meta rejected it, with Meta's own message quoted.- Capped at 5 sends per profile per minute.
Step 4 — export
POST /api/v1/exportsScope: attribution:write
Covered in full under Conversions & Revenue. In short: it
queues a run and answers 202 with a batch id; poll
GET /api/v1/exports/{id} for what happened, and read
GET /api/v1/exports/ledger for the per-delivery detail. A non-empty ledger is
the proof the four steps worked.
replay_queued
Every mutation on this page returns replay_queued and replay_error
alongside the row it wrote.
A configuration change only reaches the pipeline when a run picks it up, and
the scheduled run is hourly. These writes queue one standard export pass so the
change takes effect now, and report whether that succeeded — replay_queued: false with an replay_error means the write landed and the pass did not, so
the change waits for the next hourly sweep instead of being lost.
It says a pass was asked for. It never means anything shipped.