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:
- Token scope -- your MCP token must include
mcp:write - Workspace setting -- a workspace admin must enable MCP write-back in workspace settings
- User role -- you must be a workspace owner or admin for
previewanddry_run. Onconfirm, a token without that role is no longer refused: it gets a human approval hand-off instead - An enabled export destination -- Conversion Sync must have an active
meta_capidestination for the profile (on top of the connected Meta Ads account). That destination is what decides the pixel/dataset the events land in; thepixel_idargument 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 modeThe tool returns:
counts.scoped— source records the window + event types matchedcounts.would_enqueue— how many would actually be queuedcounts.already_delivered— already at Meta under their canonical event id, so they will not be re-sentcounts.already_queued— a job is already coming for themcounts.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 conversionsNo 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— pollGET /api/v1/exports/{batch_id}for this run's progressqueued_job_ids— the queue messages that were createdcounts— the same breakdown as preview, withenqueuedin place ofwould_enqueueobserve— 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 event | Meta event |
|---|---|
payment_received | Purchase |
order_placed | Purchase |
closed_won | Purchase |
appointment_booked | Schedule |
lead_created | Lead |
checkout_started | InitiateCheckout |
add_to_cart | AddToCart |
add_payment_info | AddPaymentInfo |
view_content | ViewContent |
search | Search |
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}:
status | what happened |
|---|---|
pending | they have not decided yet |
completed | approved and executed — result carries the same body the direct confirm returns |
cancelled | they rejected it; nothing was written |
expired | the link lapsed (45 minutes); nothing was written |
failed | they 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_recommendationgoes throughapplyProfileRecommendation(the same functionPOST /api/v1/recommendations/{id}/applyand the dashboard's Apply button use, audited inmeta_action_log), andsend_meta_conversionsgoes through the same export-ledger enqueue asPOST /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
confirmwith the sameidempotency_keyreturns 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:writescope + workspace write-back enabled. The owner/admin role is enforced ondry_run; onconfirma token without it now gets a human approval instead of a refusal (#1050).previewworks 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_logwith pre-state and rollback), soconfirmvalidates the gates and returns a structured hand-off: the dashboard URL for the one-click confirm, andPOST /api/v1/recommendations/{id}/apply(campaigns:apply) for a programmatic apply. Usediagnose_recommendationto 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, makesconfirmmint anapprovehand-off instead — and approving it runsapplyProfileRecommendation, 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_fatiguenever calls Meta. That kind returns an Ads Lab handoff URL instead.
Related
Available Tools
52 MCP tools: the onboarding golden path (provision, tracking install, connect hand-offs, conversion definitions, Meta CAPI setup), performance summaries, funnel analysis, traffic breakdowns, campaign drill-downs, customer journeys, workspace creative intelligence, recommendations, and Meta CAPI write-back
Privacy & PII
How PII masking works, workspace privacy settings, and how to configure unmasked access