Atribu
API Reference

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:

  1. A destination — POST /api/v1/exports/destinations. Which Meta dataset (or Google Ads conversion action) this profile ships to.
  2. A rule — POST /api/v1/exports/rules. Which conversion becomes which platform event.
  3. A test event — POST /api/v1/exports/test. Prove the round-trip before real data depends on it.
  4. The export — POST /api/v1/exports. Queue the run, then read GET /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

Endpoint
POST /api/v1/exports/destinations

Scope: exports:write

Request
{
  "destination": "meta_capi",
  "connection_id": "0d0e6c8a-…",
  "meta_pixel_id": "1234567890",
  "enabled": true,
  "destination_config": {
    "signal_quality": { "preset": "balanced" }
  }
}
FieldRequiredNotes
destinationyesmeta_capi or google_ads. Immutable — a PATCH that changes it is a 422.
connection_idyesA data_connections id on this profile. Its provider must match the kind: meta_capi needs meta_ads.
meta_pixel_idfor meta_capiThe dataset / pixel id from Events Manager.
google_conversion_actionfor google_adscustomers/{id}/conversionActions/{id}.
enablednoDefaults true.
destination_confignoFree-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.

Platform-Safe profiles: which Meta dataset ships (#1669)

In privacy_mode: "platform_safe" the payload is scrubbed the same way whichever dataset receives it; platform_safe_target (on the response, and on the catalog settings) decides which one does. existing, the default, keeps every ordinary Meta CAPI destination active, so the live ad sets keep optimizing on the dataset they already use, under the event names your rules send (platform_event_name_overrides). clean ships only the provisioned Clean Dataset and marks the rest suppressed_platform_safe; it is the remediation once Meta restricts the existing dataset, reached with POST /api/v1/conversion-sync/platform-safe/target. The wiring read's platform_safe.event_mapping warns when the advertiser's own pixel already fires the same event name on the same dataset (double_count_risk). The full partner flow is in Partners.

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

Endpoint
POST /api/v1/exports/rules

Scope: exports:write

Request
{
  "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_modeSends
event_valuethe conversion's own amount and currency
fixed_valuesource_config.fixed_value + source_config.fixed_currency
noneno value at all
cashthe amount actually collected — the value of a cash conversion; no value for anything that collected nothing (a gross order, a pipeline deal), which a Purchase then holds as meta_value_required_missing rather than claim money that was not received
netcash minus every refund and dispute Atribu holds for that sale when the export is built (a won dispute adds back), never below 0. A refund that arrives after the send is not re-sent — a platform cannot amend a delivered event

cash and net read the conversion row, so a candidate whose conversion is not extracted yet (normally minutes) waits for the next hourly pass instead of guessing; after 24 hours with still no conversion it is skipped as conversion_not_extracted. Neither is accepted on a pipeline-stage rule.

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.

New-customer purchase event

new_customer_event_enabled: true also sends the Meta custom event NewCustomerPurchase on a customer's first cash payment — the same is_first_payment predicate the dashboard's new-customer ROAS reads, cross-checked against the customer_type label every Purchase already carries. It rides next to the rule's own event, never instead of it: same value, currency, custom data and event_id (Meta deduplicates on the pair of event name and id, so the two never collapse). Point an ad set at it through a Custom Conversion to pay for acquisition rather than repeat orders. Off by default; never sent in HIPAA privacy mode; Google Ads has no custom events and is unaffected.

Which platform receives each sale

platform_eligibility_mode stops two platforms from both optimizing on the same sale. With the default all, a conversion whose customer clicked a Meta ad and a Google ad is sent to both, and each platform claims it.

modea platform receives a conversion when
all (default)always — every matching conversion goes to every destination
attributed_to_platformthat platform's paid touches hold the largest share of the conversion's credit under platform_eligibility_model (under last_touch: the credited touch is that platform's ad). A sale credited to Direct or organic traffic goes to no platform
any_touch_from_platformthe customer's path holds at least one paid click from that platform

platform_eligibility_model (default last_touch) is any attribution model. The decision reads Atribu's own attribution, which is computed a few minutes after the conversion lands, so the export waits for it; if it is still not computed 24 hours later the export is skipped as attribution_not_computed. Top-of-funnel conversions Atribu never attributes (product views, add-to-cart, searches) go to no platform under either non-all mode, skipped at once as attribution_not_applicable. Held exports are recorded as skipped with platform_not_credited or platform_not_in_path. Not accepted on a pipeline-stage rule.

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_modeWhat ships
skip (default)nothing; the delivery is recorded as skipped with skip_reason: "recurring_payment"
custom_eventa 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
purchasethe 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

Endpoint
POST /api/v1/exports/rules/preview

Scope: 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.

Response
{
  "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

Endpoint
POST /api/v1/exports/test

Scope: exports:write

Request
{ "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).

Response
{
  "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_src marker 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_warning is 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_state means the profile's privacy or HIPAA legal gate refused the payload; 502 means Meta rejected it, with Meta's own message quoted.
  • Capped at 5 sends per profile per minute.

Step 4 — export

Endpoint
POST /api/v1/exports

Scope: 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.

Where each event came from (action_source)

Each ledger entry carries action_source: what the Meta payload actually said. An outcome posted to POST /api/v1/events with action_source is sent with that value, and any non-website value ships without event_source_url; one posted without it is inferred as before (see Which action_source to send). A click-to-WhatsApp outcome goes to the WhatsApp Business Account's own dataset while the merchant's WhatsApp connection is live, including in platform_safe.


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.

On this page