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 destinationPOST /api/v1/exports/destinations. Which Meta dataset (or Google Ads conversion action) this profile ships to.
  2. A rulePOST /api/v1/exports/rules. Which conversion becomes which platform event.
  3. A test eventPOST /api/v1/exports/test. Prove the round-trip before real data depends on it.
  4. The exportPOST /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.

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

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_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.


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