Atribu
MCP Server

Write-Back (Meta CAPI)

Push attributed conversions to Meta Conversions API with preview, dry-run, and confirm safety flow

The send_meta_conversions tool lets you push attributed conversions from Atribu to Meta's Conversions API (CAPI). This improves Meta's ad delivery optimization by feeding real conversion data back to the algorithm.

Atribu has a second write tool with the same gates and safety flow: apply_recommendation, which applies AI media-buyer recommendations (pause / budget changes) against Meta. Most of this page covers send_meta_conversions; the differences for apply_recommendation are at the end.

The tool queues; the pipeline sends

send_meta_conversions does not call Meta. confirm writes rows to Atribu's conversion_exports ledger and enqueues one export job; the pipeline worker performs the actual delivery. This is deliberate: the worker sends under the booking cluster's canonical event_id, so a conversion that reached Atribu through several paths (browser tracker, CRM webhook, bulk sync) ships once, and Meta's 48-hour dedup collapses it against your Pixel. A second sender using a different id would count the same cash twice.

Irreversible action

Once queued, a conversion is delivered to Meta and cannot be recalled. The tool enforces a three-step safety flow to prevent accidental submissions.

Prerequisites

Before using write-back, all four conditions must be met:

  1. Token scope -- your MCP token must include mcp:write
  2. Workspace setting -- a workspace admin must enable MCP write-back in workspace settings
  3. User role -- you must be a workspace owner or admin for preview and dry_run. On confirm, a token without that role is no longer refused: it gets a human approval hand-off instead
  4. An enabled export destination -- Conversion Sync must have an active meta_capi destination for the profile (on top of the connected Meta Ads account). That destination is what decides the pixel/dataset the events land in; the pixel_id argument is only validated against it, never used to route.

If any condition is unmet, the tool returns a typed error explaining what's missing and how to fix it.


The three-step flow

Step 1: Preview

Plan the run against the export ledger. Nothing is written and nothing is queued.

Send my last 7 days of payment_received conversions to Meta in preview mode

The tool returns:

  • counts.scoped — source records the window + event types matched
  • counts.would_enqueue — how many would actually be queued
  • counts.already_delivered — already at Meta under their canonical event id, so they will not be re-sent
  • counts.already_queued — a job is already coming for them
  • counts.not_ledgered — the pipeline has not built an export candidate for them yet
  • The destinations the run would target, and the profile's legal-gate state

The preview itself is recorded in the audit trail.


Step 2: Dry-run

The same plan, recorded in the write-back audit log as intent. Still no queueing.

Do a dry-run of those conversions

No Meta test-event call

Before the tool moved onto the export ledger, dry_run posted to Meta with a test_event_code. It no longer does — a tool that never sends cannot test-send. test_event_code is still accepted and ignored. To validate payload shape against Meta's Test Events tab, use the Conversion Sync surface in the dashboard.


Step 3: Confirm

Queue the run. Requires an idempotency key to prevent duplicate submissions.

Confirm sending those conversions. Use idempotency key "april-week2-payments"

The tool returns result: "queued" — not "sent" — plus:

  • batch_id — poll GET /api/v1/exports/{batch_id} for this run's progress
  • queued_job_ids — the queue messages that were created
  • counts — the same breakdown as preview, with enqueued in place of would_enqueue
  • observe — the ledger URL for the delivery outcomes

The AI tool should generate a unique idempotency key (typically a UUID or descriptive string) and include it in the confirm call. If the same idempotency key is used twice, the tool returns the prior result instead of queueing again.

Where the answer arrives

Delivery is asynchronous. GET /api/v1/exports/ledger is the PII-free deliveries ledger: per-row status (pending → sent / failed / skipped), attempt count, the canonical event id that shipped, and the provider's trace id.


Event type mapping

Atribu outcome types map to Meta standard events:

Atribu eventMeta event
payment_receivedPurchase
order_placedPurchase
closed_wonPurchase
appointment_bookedSchedule
lead_createdLead
checkout_startedInitiateCheckout
add_to_cartAddToCart
add_payment_infoAddPaymentInfo
view_contentViewContent
searchSearch

Safety rails

Idempotency

Every confirm call requires an idempotency_key. If a confirm with the same key was already processed for this profile, the tool returns the prior result. This prevents accidental double-sends even if the AI tool retries.

The export ledger

The ledger is what makes "already delivered" answerable, and it is keyed by the canonical event id, not by which path ingested the conversion. A row already sent is never reset and never re-queued, no matter how many times you trigger the window — so a repeat confirm is a no-op rather than a second conversion at Meta.

Circuit breaker

If 3 or more confirm operations fail for the same profile within 30 minutes, the tool enters circuit-open state and rejects new confirm calls. Wait for the cooldown or investigate the failures.

Audit trail

Every operation (preview, dry-run, confirm) creates an immutable audit record with:

  • Payload hash, event count, window dates
  • The enqueue outcome on success: the batch id, the ledger row ids, and the queue message ids
  • Result status and error details
  • Request ID for traceability

Audit records are visible to workspace admins in the dashboard.



Human approval — when the agent is not the one who decides

An agent's confirm used to be executed by the agent. Since #1050 it can be handed to a person instead, as a hand-off: a URL you give them and an id you poll.

Two ways in, one object

Approval is required when your token is not a workspace owner/admin — that call used to be refused outright with insufficient_scope and no way forward — or whenever you pass require_approval: true. The second is an opt-in that works for an owner/admin token too: an agency's agent routing every Meta write on a client's account past the client is exactly what it is for.

confirm then writes nothing and returns:

{
  "status": "awaiting_approval",
  "reason": "not_workspace_admin",
  "handoff": {
    "id": "0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21",
    "url": "https://www.atribu.app/h/9tQ2mB1x…",
    "expires_at": "2026-09-05T11:45:00.000Z"
  },
  "poll": "GET /api/v1/handoffs/0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21"
}

Give url to the person who owns the account. The page shows what will change, on which account, and that an agent asked.

They must be signed in as a workspace owner or admin, and they cannot be you. Unlike connect / sign_dpa / checkout — where holding the link is the whole claim — an approve hand-off asks for authority, and it is minted by the very token whose write was refused. Opening it signed out offers a Sign in to approve button back to the same link; the decision refuses with 401 (no session), 403 (not an owner/admin of that workspace) or 403 (you are the requester), and a refusal leaves the hand-off open for the right person. Then poll GET /api/v1/handoffs/{id}:

statuswhat happened
pendingthey have not decided yet
completedapproved and executed — result carries the same body the direct confirm returns
cancelledthey rejected it; nothing was written
expiredthe link lapsed (45 minutes); nothing was written
failedthey approved, and the write itself refused — result.error says why

Three things worth knowing before you build on it:

  • Approving runs the same code the direct call runs. There is no approval-specific writer: apply_recommendation goes through applyProfileRecommendation (the same function POST /api/v1/recommendations/{id}/apply and the dashboard's Apply button use, audited in meta_action_log), and send_meta_conversions goes through the same export-ledger enqueue as POST /api/v1/exports.
  • The preview is frozen; the gates are not. What the person reads is what you asked for, captured at mint. The write's own gates run at approve time, so a recommendation someone else applied in the meantime — or a legal gate that closed — refuses then, loudly, instead of executing against a stale card.
  • Replaying confirm with the same idempotency_key returns the ORIGINAL hand-off, never a second URL. Two live links for one write means the person presses the one you are not polling.

The same object is on the REST surface: POST /api/v1/recommendations/{id}/apply and POST /api/v1/exports both accept require_approval: true in the body and answer 202 with the identical {status, handoff} shape.

preview and dry_run are unchanged and still require an owner/admin: they read the profile's plan and record an intent, and an approval cannot grant a token authority it was never given. The workspace-level gates (mcp:write, MCP write-back enabled) are hard refusals for the same reason.

Match quality

The tool no longer estimates match quality. It used to, from its own copy of the payload builder — which answered a question about a payload that was never the one shipped, since the export pipeline builds its own with richer identity resolution and the profile's privacy posture applied.

The real numbers are Meta's, and they live in Conversion Sync → Match Quality in the dashboard: the per-parameter coverage table (email, phone, fbc, fbp, external_id, ctwa_clid) read back from Meta's own EMQ, with the "we can fill N of M from data we already hold" derivation.


apply_recommendation — the second write tool

apply_recommendation applies an AI media-buyer recommendation (pause an underperformer, scale a winner, reallocate budget between ad sets) against Meta. It shares the write-back machinery:

  • Same three modes -- preview (no side effects; shows the target ad/ad-set, the planned Meta call(s), and the budget % delta), dry_run (records an audit row capturing intent), confirm (executes for real).
  • Same gates -- mcp:write scope + workspace write-back enabled. The owner/admin role is enforced on dry_run; on confirm a token without it now gets a human approval instead of a refusal (#1050). preview works without the role.
  • Same cost -- 10 units.
  • Same audit trail -- every operation writes to the same audit log admins see in the dashboard.

Differences from send_meta_conversions:

  • Idempotency key is optional. When omitted on confirm, one is derived automatically from (user, recommendation, day) — so replaying the same recommendation on the same day deduplicates, while a deliberate next-day retry is distinct. Replaying a processed key returns the existing application, not a duplicate write.
  • Confirm does not write to Meta from this tool. Since #158 the execution lives in the meta-actions layer (audited in meta_action_log with pre-state and rollback), so confirm validates the gates and returns a structured hand-off: the dashboard URL for the one-click confirm, and POST /api/v1/recommendations/{id}/apply (campaigns:apply) for a programmatic apply. Use diagnose_recommendation to inspect an application, its pre/post state, and the verification result. (This page previously described a worker queue; that queue was removed with #158.)
  • Human approval works the same way here. A non-owner/admin token, or require_approval: true, makes confirm mint an approve hand-off instead — and approving it runs applyProfileRecommendation, the same function the REST route calls. See Human approval.
  • Confirm requires an open recommendation. Recommendations that are already applied, dismissed, superseded, expired, or rolled back are rejected (preview still works as a history lookup).
  • creative_refresh_pre_fatigue never calls Meta. That kind returns an Ads Lab handoff URL instead.

On this page