Atribu
API Reference

Partners & OAuth Apps

The surface a sibling product integrates against: OAuth-app credentials, zero-bounce provisioning, and the WhatsApp join.

This page is for a consumer application — a product that provisions Atribu profiles for its own merchants and reads their attribution, rather than a business measuring its own ads. Vitrina is the reference consumer.

If you are an agency, a developer or an AI agent working on your own account, you want Credentials and the API quickstart instead.

The credential

Endpoints
POST /oauth/token
POST /oauth/revoke

An OAuth app authenticates with client_secret_basic or client_secret_post and receives an atb_live_… API key carrying a tracked oauth_app_id. POST /oauth/revoke is RFC 7009 token revocation.

Use https://www.atribu.app as your base URL. That is this spec's servers[0].url. api.atribu.app answers every operation on this page too — POST /oauth/token, POST /oauth/revoke, POST /api/v1/events and the /api/v1/partner/* routes included — by forwarding the request, body and headers unchanged, to the same handler www runs (until 2026-09-30 these answered 404 there). Either host works; pin one rather than mixing them per call. Register inbound webhooks (the URLs a provider calls back) on www.atribu.app: a provider that signs the full URL would fail validation behind the forward, which rebuilds the URL with the www host.

This is a different OAuth surface from the one an AI agent uses. The agent bootstrap is /oauth/mcp/* — dynamic client registration, PKCE, a human consent screen, an atb_user_… token. See the API quickstart.

Connect scopes

Two connect scopes carry a Partner App's whole relationship to a Profile. Both are connectionless — the same class as analytics / analytics_pii / attribution_write below, not tied to one provider hand-off the way whatsapp / instagram / email / calendar are.

Connect scopeMintsReaches
connectionsconnections:read, connections:writeThe connect rail only: start a hand-off, poll the hand-off you just minted, list and finalize a pending connect, trigger a sync and read its status, disconnect. Nothing analytics- or attribution-shaped. The default every profile you provision gets.
attributionEvery attribution-shaped API scope — analytics:read, campaigns:read, conversions:read, realtime:read, customers:read, visitors:read, goals:write, attribution:write, tracking:write, reports:write, exports:read, exports:write, events:write, campaigns:apply, creatives:write — plus connections:read / connections:writeAnalytics and PII reads, attribution writes, Conversion Sync configuration, outcome ingest, and ad-platform writes. Product-neutral — never a per-partner name. You do not request this scope. Atribu sets it on the grant for you; see "Connect broker vs. the attribution Add-on" below.
analyticsanalytics:read, campaigns:read, conversions:read, realtime:readRead-only analytics, no PII. Still a valid request for an app that wants a narrower grant than attribution.
analytics_piiThe analytics set, plus customers:read, visitors:readAdds the two PII reads. Still valid, granularly.
attribution_writegoals:write, attribution:write, tracking:write, reports:writeThe write tier alone, without reads or exports. Still valid, granularly.

analytics, analytics_pii and attribution_write are not retired by attribution — an app (or an MCP agent) that wants to pick scopes one at a time still requests them individually. attribution exists for the one case that needs the whole bundle at once, atomically: the entitlement operation below.

Connect broker vs. the attribution Add-on (#1455)

A profile you provision starts in exactly one tier, and moves between the two only when you say so.

TierWhat it reachesHow you get it
Connect brokerChannel connections (WhatsApp, Instagram, email, calendar) and the ad-platform connection itself, plus the connectivity-only reads ADR 0021 already serves — structure, the click-to-WhatsApp greeting. No analytics, no PII, no attribution.scopes: ["connections"] — the default.
Attribution Add-onEverything: analytics and PII reads, attribution writes, Conversion Sync configuration, outcome ingest, ad-platform writes.You never request it. Atribu grants it — see below.

You never ask for attribution on a consent, an /oauth/authorize call, or a POST /api/v1/profiles body. Atribu sets it as a side effect of the entitlement operation you already call to turn the pipeline on:

Endpoint
PUT /api/v1/profiles/{profileId}/entitlements/atribu_attribution
Turning the Add-on on
{ "enabled": true }

{ "enabled": true } upserts the profile's grant to {connections, attribution} — replace semantics, clearing revoked_at if the grant had been revoked. The call itself never returns a secret: mint a fresh key against the widened grant afterward, grant_type=client_credentials, the same way you already do for rotation.

{ "enabled": false } narrows the grant back to {connections} rather than revoking it outright — a full revoke would have taken the free connect rail down along with the Add-on. The connect broker tier keeps working. Every API key minted under the wider {connections, attribution} grant is revoked as part of the narrowing, so an Ads-scoped key stops authenticating inside the ~10 second auth cache window. Mint a fresh core-tier key against the narrowed grant afterward — the same grant_type=client_credentials rotation you run after ON.

Reads stay open on the connect broker tier

A connect-broker-only profile is never a 403 wall. Every read route (analytics, campaigns, conversions, …) stays reachable and simply answers empty — a connectivity-only profile has no attribution numbers yet, for any credential asking. What gates is the write side: the exports/* writes, POST /api/v1/events, the attribution writes, and the ad-platform writes all answer 409 invalid_state (code entitlement_required, naming atribu_attribution) on an app-provisioned profile with the Add-on off — for any principal, including your own merchant's signed-in session, not only a delegated key. A profile a human provisioned directly never sees this at all (ADR 0021).

Two failures to branch on when a write 403s or 409s:

ResponseWhat it meansWhat to do
403 insufficient_scope, naming exports:write (or another attribution scope)Your key was minted before the Add-on went on, so it still only carries {connections}Mint a fresh key against the widened grant and swap it in.
409 invalid_state, code entitlement_requiredThe Add-on isn't active for this profile at allNo key fixes this. Call the entitlement endpoint with { "enabled": true }, or tell the merchant to buy the Add-on.

Migrating a profile provisioned before #1455

An existing profile still carries the old default grant, {analytics, attribution_write}. Move it onto the new shape the same way you already re-consent a profile for any other connectionless-scope change: replay provisioning.

POST /api/v1/profiles — replay
{
  "external_ref": "dealer_8812",
  "scopes": ["connections"]
}

For an external_ref-mode app this has no browser step — the same auto-allow naming the profile a consent lands on already describes (ADR 0020). The call narrows the grant to {connections}. Mint a new key against it, swap it into your integration, then revoke the old one — the mint → swap → revoke rotation you already run.

The old key keeps working during the swap

While your previous grant is still live, the connect rail also accepts a key minted under the old attribution_write grant, so a merchant mid-swap never loses connect access. Once you revoke the old key, only connections / attribution scopes work there.

Verify by exercising the connect rail with the new key — list connections, start a hand-off, read sync status — and confirming nothing on it answers 403.

Provisioning a merchant

Endpoint
POST /api/v1/profiles

The app-credential branch provisions end to end: it creates the workspace if needed, the profile, the tracking key, and returns the credential the consumer stores for that merchant. It is idempotent on a natural key, so a retried provisioning is not a second profile.

A partner-provisioned profile is deliberately not attribution-entitled

Such a profile answers blocked on readiness's attribution_enabled step — the rest of the checklist is still evaluated and still reports what it finds. "Well configured" and "will never attribute anything" are both facts a consumer needs. Grant the entitlement below to unblock it.

Archiving a profile you provisioned

Endpoint
DELETE /api/v1/profiles/{profileId}

Retires a merchant your app provisioned, and its dealer workspace with it. Terminal — there is no un-archive.

The same client_secret_basic credential as provisioning. The profile must be one your app provisioned — an unknown id, another app's profile, or one a human created all answer 404, identically.

Provisioning gives every merchant a workspace of its own, so archiving the profile and archiving its workspace are the same transaction: every membership is removed, pending invitations are revoked, the workspace's API keys are revoked, its webhook subscriptions are paused, and its non-audit data is purged — the same effect DELETE /api/v1/workspaces/{workspaceId} has for a signed-in owner. Every live grant on the profile — any app's — and every delegated API key each one minted are revoked as part of the same call.

Response
{
  "data": {
    "profile_id": "…",
    "workspace_id": "…",
    "archived": true,
    "workspace_archived": true
  }
}

A 409 means the workspace is not exclusively yours anymore

If a human has since added a second live profile to that workspace, this call answers 409 invalid_state rather than silently widening to destroy a profile your app never provisioned. Archive or move the other profile first.

Idempotent. The first call and every later call both answer 200 with archived: true — a DELETE that 404s an already-archived profile would make a retry look like a failure.

By default, Atribu decides which profile a consent lands on from the email in the signed id_token_hint. That keys a merchant on a person, and it is the wrong key for a consumer app: one operator connecting two merchants lands both on one profile, and two admins of one merchant land on two.

So an app can be switched to profile_resolution: "external_ref", in which case you name the profile on every /oauth/authorize — with the same external_ref you provisioned it under:

Signing the hint with the tenant you mean
const idTokenHint = await signIdTokenHint({
  jwtSigningSecret: process.env.ATRIBU_JWT_SIGNING_SECRET!,
  issuer: "your-client-id",
  subject: user.id,
  email: user.email,
  externalRef: merchant.id, // ← the `external_ref` you provisioned under
});

That emits an external_ref claim inside the hint. Consent then resolves (client_id, external_ref) among the live profiles your app provisioned, issues the authorization code against that profile, and skips every email-keyed branch — including the "this email belongs to a real Atribu user, make them sign in" gate, which has nothing to protect here: your app can only name a profile it provisioned itself. It creates no workspace and no membership, and it records an audit event on the profile naming who consented, for which app, and for which scope.

The consent screen is skipped entirely for an external_ref app. The person already chose to connect inside YOUR product, and your app can only name a profile it provisioned itself — an Allow/Deny screen would add nothing but Atribu branding. So /oauth/authorize resolves the profile from the id_token_hint right there and, on success, goes straight to the provider's own consent dialog (or, for a connectionless scope, straight back to your redirect_uri with the code) — the same audit event is recorded either way. The two error_description values below still apply, now as the FIRST response /oauth/authorize can give: a missing or unknown ref never renders anything, it redirects immediately. An email-mode app is unaffected and keeps rendering the Allow/Deny screen.

The connect hand-off page (POST /api/v1/connections/{provider}/handoff) follows the same rule: when your return_url is present, the hand-off's /h/<handle> page redirects to the provider's consent immediately — at most a neutral, unbranded interstitial while the browser navigates — instead of waiting for the person to press Continue. Omit return_url and the page keeps today's Continue-button card.

Two failures come back as OAuth error redirects to your registered redirect_uri, with your state replayed — branch on error_description:

error_descriptionWhat happenedWhat to do
external_ref_requiredThe hint carried no external_ref claim (or a blank one)Sign the hint with externalRef.
unknown_external_refNo live profile of yours is provisioned under that refPOST /api/v1/profiles for this merchant, then retry.

Both answer error=invalid_request. Neither ever creates a profile: an unknown ref is a question, not an instruction.

Switching modes is a one-way door for existing merchants

Apps stay on profile_resolution: "email" until Atribu switches them, and an external_ref claim on an email-mode app is ignored — so you can start sending it before the switch. But from the moment the switch lands the claim is mandatory, and every profile you still expect to reach must already carry a provisioned_external_ref. Profiles created by the old email path carry none; adopt them through the provisioning API first, or their consents start answering unknown_external_ref.

Every scope works this way — whatsapp, instagram, email, calendar, calendar_read, and the connectionless connections / attribution / analytics / analytics_pii / attribution_write. Nothing after consent changes: the authorization code carries the profile immutably, every connect page reads it back, and the exchange mints the key against it.

Adopting a profile that predates external_ref (#1415)

Endpoint
POST /api/v1/profiles/{profileId}/adopt

Provisioning keys a merchant on (your client_id, external_ref). If your consent flow ever created a profile a different way — most commonly the email-keyed flow ADR 0020 replaced — that profile carries provisioned_by but no external_ref, and the first POST /api/v1/profiles call you make for it under the new flow would mint a second profile rather than finding the first. Adopt is the one-time bridge: name the existing profile with the external_ref you will use from now on.

The same client_secret_basic credential as provisioning. Preconditions, all four: the profile was provisioned by your app, it carries no external_ref other than the one you send, it is not archived, and your app already holds a live grant on it (profile-level, or per-connection from a completed WhatsApp/Instagram consent — either counts). Any other profile — unknown, another app's, or archived — answers 404, identically; there is no way to tell which of the three you hit.

Request
{
  "external_ref": "dealer_8812",
  "scopes": ["analytics"]
}

Stamps external_ref, upserts the profile-level grant with scopes (the same validation POST /api/v1/profiles applies), and writes an audit event, all in one call. Idempotent on the profile: a repeat call with the same external_ref answers 200 with created: false; only the call that performs the stamp answers 201. A repeat with different scopes still updates the grant — the same re-consent shape provisioning uses. A profile that already carries a different external_ref answers 409 — including when a concurrent POST /api/v1/profiles claims that exact ref for a different profile while your adopt call is in flight; the underlying unique index arbitrates either way.

Not part of the Node SDK

Like POST /api/v1/profiles itself, this is an app-credential call your backend makes directly with client_id/client_secret — there is no @atribu/node method for it. The request/response shapes are in the OpenAPI document and its generated types.

Declaring the attribution entitlement (#1418)

Endpoint
PUT /api/v1/profiles/{profileId}/entitlements/{feature}

atribu_attribution is off for every profile you provision — Atribu never turns it on by itself, not on provisioning and not on the first ad-platform connection. This endpoint is the explicit switch: call it when your own Add-on, plan, or subscription state changes for a merchant.

Request body
{ "enabled": true }

Today {feature} is only ever atribu_attribution — the path takes a parameter rather than a fixed segment because profile_entitlements is a per-feature table on the engine side, so a second feature gets a value here rather than a new route.

Same client_secret_basic credential as POST /api/v1/profiles and the members endpoints above — never a session token, never an atb_live_ key. The check is the same stamp: {profileId} must carry provisioned_by set to your client_id. Any other profile — unknown, another app's, or archived — answers 404, identically; this endpoint never confirms that a profile id exists or which app owns it.

Turning it on starts what a human-entitled profile gets: the ad-platform sync's performance-shaped work (insights/spend, messaging metrics, attribution, conversions, exports) resumes, and Atribu immediately enqueues the standard backfill window for every ad-platform connection the profile already holds. You do not also need to call POST /api/v1/connections/sync.

Turning it off returns the profile to connectivity-only mode: campaign/ad structure and conversational configuration (the click-to-WhatsApp ad greeting) keep syncing nightly, but the performance-shaped work stops. Nothing is deleted.

Connectivity-only mode, precisely (ADR 0021)

While atribu_attribution is off, a meta_ads or google_ads connection on this profile still gets its nightly (and on-demand) structure sync — campaigns, ad sets, ads and creatives: names, status, the ad set's destination, and the creative's call-to-action type and greeting (the source the click-to-WhatsApp ad greeting read prefers). It does not get insights, spend, messaging metrics, attribution recompute, conversion sync, or exports — so GET /api/v1/connections honestly reports delivers_spend: false for such a connection, because no spend row is ever written for it. This is the free-tier state every profile you provision starts in; turning the entitlement on is what upgrades it to full attribution.

Response — 200
{
  "data": {
    "profile_id": "…",
    "feature": "atribu_attribution",
    "enabled": true,
    "changed": true
  },
  "meta": { "profile_id": "…" }
}

Idempotent. Calling this again with the same enabled value answers 200 with changed: false, writes no second audit event, and enqueues no second backfill — a duplicate call (your own retry, or an at-least-once delivery on your side) is a true no-op, not a repeated sync pass. Every real change (changed: true) writes an audit event naming your app and the direction and, on the OFF→ON transition specifically, enqueues the standard backfill window exactly once.

Reconciling a merchant's team (#1252)

Endpoints
GET    /api/v1/workspaces/{workspaceId}/members
PUT    /api/v1/workspaces/{workspaceId}/members
DELETE /api/v1/workspaces/{workspaceId}/members/{userId}

Once a merchant's workspace exists, your app is the source of truth for who is on its team — Atribu is the headless engine underneath it, and sends the merchant's people no email at all. So this is not an invitation: PUT states a membership and Atribu mirrors it. Calling it again with the same body changes nothing.

All three routes take the same client_secret_basic credential as POST /api/v1/profiles — never a session token, never an atb_live_ key.

Only workspaces your app provisioned

The check is the same stamp POST /api/v1/profiles writes: the workspace must hold a profile with provisioned_by set to your client_id. A bare workspace from the app-credential branch of POST /api/v1/workspaces qualifies once it holds one such profile. Any other workspace — one you did not provision, or one that does not exist — answers 404, identically.

PUT finds-or-creates a passwordless user for email (the same lookup provisioning uses for the merchant owner) and sets their workspace_role — admin, analyst, or guest; never owner, see below. admin and analyst reach every profile in the workspace regardless of profiles; guest reaches only the profiles you name there.

PUT — grant a guest two profiles
{
  "email": "[email protected]",
  "workspace_role": "guest",
  "profiles": [
    { "profile_id": "…", "profile_role": "viewer" },
    { "profile_id": "…", "profile_role": "manager", "permissions": ["view:dashboard", "view:attribution"] }
  ]
}

profiles, when present, is that person's complete profile access in this workspace: every profile listed is granted, and every active profile membership not listed is removed. Omit profiles entirely to change only the workspace role and leave existing profile access untouched. permissions absent means the role's default bundle; present means exactly that list — an unknown key is a 400 naming it, never silently dropped.

The response is 201 the first time this reconciles a new membership, 200 on every later call that updates one — the same idempotent-on-email shape POST /api/v1/profiles uses for external_ref.

The owner is off limits

The workspace owner was set by provisioning and is the row RLS resolves a direct sign-in through. Assigning owner (the request does not even offer the value), changing the owner's role, or removing the owner all answer 409 invalid_state.

DELETE soft-removes the workspace membership and every profile membership that person holds in this workspace — rows are kept for audit, not erased. It is idempotent: calling it on someone already removed, or never a member, answers 200 {"removed": false}, not a 404.

Disconnecting a merchant's connection

Endpoint
POST /api/v1/connections/{id}/disconnect

Your atb_live_… key can disconnect a Meta, GoHighLevel, Stripe or other connection on a profile your app provisioned — the one case where there is no Atribu person to sign in and do it themselves. Same effect as a merchant disconnecting it in the Atribu console: the provider's push channel is stopped, the row is deleted, connection.revoked is delivered to your webhook subscriptions, and an audit event is written. There is no Atribu person behind the write, so its actor_user_id is null; the metadata names your app instead.

Your own app-provisioned profiles only

The check is the same stamp provisioning writes: profiles.provisioned_by must equal your client_id. A human-owned profile, or one another app provisioned, answers 403 even with a valid connections:write key — identical to the refusal a direct admin key has always gotten. A connection mid-sync is 409; wait for it to finish.

This is not DELETE /api/v1/connections/{id} — that route gives up your app's own grant on a connection and leaves the merchant's connection in place. This one removes the connection itself.

Returning the merchant to your app

A consumer's connect flow bounces the merchant through Atribu and back. The return_url's origin must be one your app can receive the bounce on: any origin from your app's redirect_uris works automatically, since Atribu is the OAuth provider and a redirect_uris entry is already your own page. allowed_return_origins is only for narrowing that set, or adding an origin your redirect_uris don't cover — an origin outside both is refused rather than redirected to. When one consent exposes several accounts, the merchant lands on the pending-selection picker — see Connections.

Registering the app

Endpoints
POST   /api/v1/admin/oauth-apps
GET    /api/v1/admin/oauth-apps/{id}
PATCH  /api/v1/admin/oauth-apps/{id}
DELETE /api/v1/admin/oauth-apps/{id}
POST   /api/v1/admin/oauth-apps/{id}/rotate-client-secret
POST   /api/v1/admin/oauth-apps/{id}/rotate-jwt-secret

Registration is admin-gated — it is Atribu operations work, not self-serve. The two rotations are separate on purpose: client_secret authenticates the app at the token endpoint, jwt-secret signs the id_token_hint that carries the merchant's identity into a consent detour. Rotating one must not invalidate the other.

GET reads an app's settings back — including profile_resolution, which PATCH is also how it is switched ("email" | "external_ref"). Neither ever returns the client-secret hash or either signing secret: those are shown once, at create and rotate time.

Both also return the app-level DPA (#1668): dpa_status (not_accepted | accepted | withdrawn), dpa_version, dpa_accepted_at, dpa_accepted_by — read only, because accepting a contract is a signed-in person's act, not an operations call — and owner_workspace_id, the partner's own Atribu workspace whose owners and admins accept it. POST and PATCH set owner_workspace_id. See Legal → The DPA for a partner app.

The Shopify equivalents (/admin/shopify-apps) register a merchant's custom-distribution app and its OAuth scopes; DELETE is a kill-switch.

Healthcare profiles never receive customers:read or visitors:read (policy S5)

A profile is healthcare-family when its workspace is marked is_healthcare_agency, or when its privacy_mode is platform_safe or hipaa. Your app's keys for such a profile never carry customers:read, the person-level read behind /api/v1/customers and /api/v1/customers/{id}/journey, nor visitors:read, the per-visitor browsing histories behind /api/v1/visitors. For a clinic, a journey is a named patient plus the treatment pages they browsed, and an anonymous visitor becomes a named patient as soon as they identify. That is health information about an identifiable person (Ley 21.719 Art. 16 bis), and it is the context Platform-Safe removes before Meta sees it.

  • Minting. POST /oauth/token leaves customers:read and visitors:read out of the key. This applies to the attribution bundle too, and nothing else in the bundle changes: campaigns:read, conversions:read, exports:read, campaigns:apply and the connect rail all stay. Read the token response's scope to see what the key holds. If Atribu cannot read the profile's healthcare settings, it mints no key and answers server_error. Retry the call.
  • Granting. On a healthcare-family profile, scopes: ["analytics_pii"] on POST /api/v1/profiles or POST /api/v1/profiles/{profileId}/adopt answers 403 healthcare_scope_forbidden, and the grant stays as it was. Ask for analytics instead.
  • Keys already issued. When a profile becomes healthcare-family, Atribu revokes every live key an app holds on it that carries customers:read or visitors:read. That happens on each write that can make it one: privacy_mode on the two calls below, the conversion-sync catalog, and the workspace's is_healthcare_agency flag. A revoked key stops authenticating within about 10 seconds. Mint a replacement with grant_type=client_credentials; it comes back without both scopes. Keys a person issued for themselves are not affected.

Mark a clinic before its first attribution key

Send privacy_mode: "platform_safe" when you provision the clinic, or on the entitlement call that turns attribution on. Atribu stores the mode before it writes or widens your grant, so the first key you mint afterwards is already narrowed. You never hold a key with customers:read for a clinic, not even briefly.

POST /api/v1/profiles
{
  "external_ref": "clinic_4471",
  "name": "Clínica Dental Norte",
  "email": "[email protected]",
  "privacy_mode": "platform_safe"
}
PUT /api/v1/profiles/{profileId}/entitlements/atribu_attribution
{ "enabled": true, "privacy_mode": "platform_safe" }
  • Accepted values: standard, platform_safe, hipaa. Anything else, custom included (it needs a privacy_config only the conversion-sync catalog composes), answers 400 validation_error.
  • Your app sets the mode on a standard profile. Re-sending the stored value is a no-op. Any other change (a downgrade, a switch between platform_safe and hipaa, anything away from custom) answers 409 invalid_state and writes nothing: that is the merchant owner's call in the conversion-sync catalog. Omit the field on replays that do not mean to set it.
  • The response echoes privacy_mode when you sent it. Provisioning also returns revoked_keys. The entitlement call counts revoked keys in grant.revoked_keys.
  • If the mode or the key revocation cannot be written, the call answers 503 service_unavailable and your grant is not touched. The mode may already be stored, and a first provisioning call may already have created the merchant. Both calls are idempotent, so retry.
  • An entitlement delivery discarded as stale (ignored_stale: true) writes no mode either.
  • /oauth/token checks the profile again after it creates each key, so a key minted while the mode is changing is caught. client_credentials then returns a narrowed key. The authorization-code exchange answers server_error instead, and the code is spent: run the consent again. If Atribu cannot read the profile's settings at that moment, it revokes the key and answers server_error. Retry.
  • platform_safe holds conversion exports until the profile has an accepted DPA, and hipaa until it has a BAA. See Legal → The DPA for a partner app.

A healthcare key reads journeys through the two projected reads in the next section, GET /api/v1/conversions/{id}/journey and GET /api/v1/customers/journey?customer_key=, under conversions:read. They never contain the person, the pages, the device, the location, the session, the anonymous id or customer_profile_id. Join them to your own record of the contact by customer_key. /api/v1/customers, /api/v1/customers/{id}/journey and /api/v1/visitors answer 403 insufficient_scope.

Journey reads without customers:read

Two reads return the marketing path of a conversion or a person without returning the person. They open at conversions:read, which every attribution bundle carries, and the scope decides the shape:

GET /api/v1/conversions/{id}/journey             conversions:read
GET /api/v1/customers/journey?customer_key={key} conversions:read
  • With customers:read both answer the full JourneyEvent page (URL, path, device, geo, session), exactly as before.
  • Without it both answer JourneyEventProjected rows:
{
  "touch_id": "uuid",
  "kind": "web_touch",
  "at": "2026-09-20T14:02:11Z",
  "channel": "Paid Social", "source": "ig", "medium": "paid",
  "campaign": { "external_id": "1203…", "name": "Spring promo" },
  "ad_set":  { "external_id": "1203…", "name": "Lookalike 1%" },
  "ad":      { "external_id": "1203…", "name": "Video A", "creative_thumbnail_url": null },
  "click_id_kind": "fbclid",
  "join_method": "link_token",
  "is_credited": true, "credit_share": 1, "credit_model": "last_touch", "in_window": true
}

kind is web_touch, synthetic, organic_interaction, inherited, proxy_click or outcome. external_id is the ad platform's own id. click_id_kind names the kind of click id (gclid, gbraid, wbraid, fbclid, ttclid, msclkid, ctwa_clid, …); the value never crosses. join_method says how the touch is tied to the person: ctwa_referral, click_id, link_token or anonymous_id, or null when none is proven. in_window is true when the touch is on the attribution path under credit_model, zero-weight positions included.

Never in a projected responseFields
The personname, email, phone, customer_profile_id
Pagesurl, path, referrer_domain, event_payload
Device and geodevice, browser, os, country, city
Browser identitysession_id, anonymous_id

These fields are absent from the JSON, not null.

The conversion. {id} is the outcome_event_id, a conversions.id, or your own event key. Without customers:read, meta.conversion is {outcome_event_id, conversion_key, occurred_at, value_amount, currency, credit_model, customer_key}, where customer_key is the user_traits.external_id you ingested for that person (null if you never sent one). A conversion linked to nobody answers 200 with no rows.

The person. GET /api/v1/customers/journey?customer_key= names the person by your own customer id, so you never store Atribu's customer_profile_id. The page covers every touch and outcome of that person, across devices and repeat conversions, oldest first, with the same cursor / limit (max 100) / model parameters. meta.person is {customer_key, conversion_count, touch_count, first_touch_at, last_touch_at, conversions[]} with the newest 50 conversions; with customers:read it also carries first_name, last_name and identifiers. ?email= or ?phone= instead of customer_key need customers:read and answer 403 insufficient_scope without it. A merged customer resolves to the one it was merged into; an unknown key, an erased customer and another profile's customer are all 404 customer_not_found, and an unknown conversion handle is 404 conversion_not_found: branch on the code, since not_found also means a route this deployment does not serve. Nothing is ever created.

Identity resolution stays inside Atribu: you send your key, and the touches come back keyed by it.

When your contact is deleted: erase it here (#1770)

You are the merchant's processor for the people you ingest, and Atribu is yours. When a contact is deleted on your side, whether by your own archive flow or by a data-subject request with a legal deadline, cascade it with your key:

POST /api/v1/customers/erasures   attribution:write
{ "request_ref": "crm:contact-archive:8f1a2c", "customer_key": "c9d2e8f1-cust-8841" }

Keep the receipt it returns (erasure_id, status, requested_at, completed_at) as your record of the cascade. Send the same request_ref again on every retry: it returns the original receipt and erases nothing twice. Use a new request_ref for each person; reusing one for a different customer is refused. It works with your API key only, and without the attribution add-on. Conversions stay in the merchant's reports without a person attached; the key stops resolving on every read. Stop sending events with that external_id too, because a new event creates a new customer. Details: Customer erasure by your key.

For pages your app renders itself, such as a quote or a booking confirmation, mint link tokens by the same key: see the partner_page recipe.

Platform-safe for sensitive verticals (#306, #1669)

A profile whose outcomes carry health or other sensitive context runs in privacy_mode: "platform_safe". Atribu then sends Meta a scrubbed payload: origin-only URLs, the allowlisted custom_data fields, hashed identities, neutral event names, no health context. Hashed email and phone are kept: Meta requires them hashed and they are not what its health filter reads.

Where it goes is platform_safe_target. Meta categorizes a data source (the domain and what the business offers), not single events, and a dataset Meta has not restricted accepts Lead, Schedule and Purchase with value. So by default (existing) the scrubbed feed goes to the profile's ordinary Meta CAPI destination(s): the dataset the merchant's live ad sets already optimize on. Nothing is recreated and Meta's learning is kept. The Clean Dataset is the remediation for a dataset Meta has restricted (clean): a fresh dataset in the merchant's ad account, and then the only Meta feed. Every other Meta CAPI destination is suppressed (routing: "suppressed_platform_safe"), and a clean profile with no Clean Dataset sends Meta nothing. Read the target on GET /api/v1/conversion-sync/catalog (settings.platform_safe_target) and on GET /api/v1/exports/destinations.

All calls below need the attribution bundle's exports:write / exports:read, so the profile's attribution entitlement must be on.

1. Switch the profile. PUT /api/v1/conversion-sync/catalog with privacy_mode: "platform_safe" (re-send the current privacy_config; the PUT replaces it). On the default existing target the profile now sends the scrubbed feed to its ordinary Meta destination, once the DPA is accepted (below).

2. Check the dataset in Events Manager, and the event names. Meta exposes no API field for a dataset's category, so a person checks it: GET /api/v1/conversion-sync/wiring returns the steps in platform_safe.categorization.checklist (Events Manager → Datasets → Settings → Manage data source categories, then Diagnostics for blocked Lead / Schedule / Purchase). platform_safe.event_mapping lists the event name each rule sends to each destination. Under existing that name must be the one the live ad sets optimize on; change it on the rule (platform_event_name_overrides) or disable the rule. When the merchant's own pixel or CRM already fires the same name on the same dataset, double_count_risk is true (other_sources_7d foreign events over 7 days, from the dataset's own counts). Pick a name the pixel does not send, or disable that rule, or it counts twice.

3. Only when Meta restricts the dataset: switch to a Clean Dataset. POST /api/v1/conversion-sync/platform-safe/target with {"target": "clean"}. It runs the provision below, moves the feed to the Clean Dataset only if one exists afterwards, and returns ad_sets_to_recreate: the live ad sets still optimizing on the old dataset, each with its Clean Dataset target. Apply them with POST /api/v1/conversion-sync/platform-safe/repoint. If the provision answers action_required, the target stays existing (switched: false) so the profile keeps sending instead of sending nothing. Going back ({"target": "existing"}) is refused with 409 clean_dataset_has_live_ad_sets while ad sets on the Clean Dataset are still delivering (or Meta cannot be read to check), unless you send confirm_live_ad_sets: true.

The provision itself is POST /api/v1/conversion-sync/platform-safe/provision with an empty body. It is safe to call on every setup run, and a Clean Dataset that exists afterwards sets the target to clean:

AnswerMeaning
200 status: "provisioned"The Clean Dataset exists and carries one neutral custom conversion ("Atribu · Schedule", "Atribu · Purchase", …) per tracked outcome type.
200 status: "already_provisioned"It already covered every tracked outcome type. Nothing was sent to Meta and nothing was written.
200 status: "action_required"A person has to act in Meta. reason: "ads_management_missing" with missing_permissions: ["ads_management"]: the merchant must reconnect Meta Ads and keep the manage advertisements permission. reason: "pixel_slot_taken": the ad account's own pixel holds the only self-created-pixel slot, so the Clean Dataset has to be created by hand in Events Manager (manual_steps).
409 platform_safe_requiredStep 1 was skipped.
409 meta_connection_requiredNo Meta Ads connection to use: none, or several with no Meta CAPI destination naming one.

Every 200 carries checks (privacy_mode, meta_connection_id, ads_management, outcome_types_tracked, outcome_types_mapped, dpa_status, platform_safe_target). The Meta connection is the one an enabled Meta CAPI destination names, or else the profile's single connected Meta Ads account. Default dataset name: "Atribu Clean Dataset" (dataset_name overrides it).

The DPA. Atribu holds every Platform-Safe export at the legal gate until the DPA is accepted, whichever the target. Your key cannot accept it. For a profile your app provisioned, you accept it once, for the app: an owner or admin of your app's workspace opens Workspace settings → Compliance in the Atribu console and accepts it under "Partner apps". Every profile your app has provisioned then reads dpa_status: "accepted", and every one it provisions later is accepted at birth — no step per merchant (details). Only a profile your app did not provision needs its own owner to accept: call POST /api/v1/legal/dpa/handoff and send them the URL it returns.

Reading the wiring. GET /api/v1/conversion-sync/wiring answers a platform_safe block for this profile (it is null in every other mode):

  • target and target_destination_ids (the destinations that receive the feed). results holds only their rows; the rest are listed in suppressed_destination_ids, without verdicts.
  • Under existing: the rows keep their own remediation (an in-place wire_optimization is valid on the same dataset), there is no provision and no recreate step, and the one Platform-Safe remediation is switch_to_clean_dataset.
  • Under clean: platform_safe_dataset: "missing" with remediation provision_platform_safe_dataset, or "provisioned" with the clean_dataset_id. A rule row that would have said wire_optimization says recreate_ad_set instead, with the target and ad_set_ids. ad_sets_to_move lists every live ad set still optimizing on the old dataset, with its target and the Ads Manager steps. It is read only on ?force=1 and on POST /api/v1/conversion-sync/wiring/recheck, and is null on a cached read.
  • dpa_status: "not_accepted" with remediation sign_dpa (POST /api/v1/legal/dpa/handoff) until the DPA is accepted. A profile your app provisioned reads "accepted" once your app's DPA is accepted, and sign_dpa does not appear for it.
  • categorization.events_not_accepted: an open alert means Atribu sent Lead, Schedule or Purchase on at least 3 of the last 7 days and Meta counted none of them. The dataset may be restricted: run the checklist, and switch to clean if Meta blocks those events.

Clean Dataset ad sets optimize on the standard event. A recreated ad set names the Clean Dataset and the standard event (optimize_on: "standard_event": {pixel_id, custom_event_type}), not a custom conversion. A neutral custom conversion's rule has to read Atribu's atribu_src custom parameter, and Meta's Core Setup, the first restriction tier, strips every custom parameter, so a custom-conversion ad set would stop receiving conversions exactly when Meta restricts the account. The custom conversions are still created, to name the events neutrally in Events Manager.

What cannot be automated. Meta freezes a published ad set's dataset, conversion event, custom conversion and optimization (error subcode 3260011), and a copy made through the API inherits the freeze. So no existing ad set can be moved onto the Clean Dataset in place. The fix is a new ad set that names the Clean Dataset when it is created. It gets a new id, restarts Meta's learning phase and starts its reporting from zero, and the original must be paused. This is why existing is the default: on an unrestricted dataset none of it happens. The wiring's cannot_automate list says this in so many words under clean; show it to the merchant next to the steps. "Cannot be automated" means "cannot be done in place": Atribu can build the new ad set for you, from your own UI, behind a consent screen, as the next section describes.

The rebuild described above can be done by Atribu instead of by hand, from your own UI, on two routes:

RouteConfirmation flagApplies to
POST /api/v1/conversion-sync/platform-safe/repointconfirm_recreate: trueA Platform-Safe profile whose platform_safe_target is clean: moves its live ad sets onto the Clean Dataset's conversions.
POST /api/v1/conversion-sync/wiring/ad-setsconfirm_duplicate_swap: true + duplicate_swap_mode (switch or run_both)Any profile: points ad sets at a rule's conversion event.

Both work in two rounds. The first call (no confirmation) re-points what Meta allows and hands back the frozen ad sets in needs_recreate / needs_duplicate_swap, untouched. Show the consent screen, then repeat the call with the confirmation flag and the person's consent. Each rebuilt ad set is copied paused, its ads are copied, the copy goes live, and the original is paused last (never deleted; on run_both it keeps running).

The consent contract. With an API key (your OAuth-app key included) or an MCP user token, a request that confirms a rebuild must also carry:

POST /api/v1/conversion-sync/platform-safe/repoint
{
  "ad_set_ids": ["120200000000000002"],
  "confirm_recreate": true,
  "consent": {
    "version": "2026-09-23.duplicate-swap.v1",
    "text_hash": "0168f7ab61559b4e1b8d6b8b1456d19cf58f546c7d4a4a6a7be4375a0a1dadff",
    "accepted_by": "your-user-8f2c",
    "accepted_at": "2026-09-23T14:05:00Z"
  }
}
  • version: the current consent text version, 2026-09-23.duplicate-swap.v1.
  • text_hash: SHA-256 (lowercase hex, UTF-8) of the consent text exactly as your screen rendered it, in one of the two locales below.
  • accepted_by: your own identifier for the person who accepted (your user id is enough). Atribu stores it in the audit trail beside the rebuild.
  • accepted_at: when they accepted (ISO 8601), at most 24 hours before the call.

Render the text verbatim, one locale, as plain text (- lines are list items). Do not paraphrase it: a paraphrase has a different hash and is refused.

Consent text · en · 2026-09-23.duplicate-swap.v1
Meta locks an ad set's conversion settings once it has been published, so they cannot be changed in place. To make these ad sets optimize for the new conversion, Atribu will create a copy of each one and switch the copy on. This is exactly what that means:
- Each copy is a new ad set with a new ID. It keeps the same budget, audience, schedule and ads, but its results start from zero and do not merge with the original's history in Ads Manager.
- Meta's learning phase starts over on each copy (typically around 50 conversions to stabilize), so delivery can fluctuate for a few days.
- The copy is created paused and switched on only after all its ads are in place. The original is then paused, never deleted, and can be reactivated in Ads Manager at any time.
- Between the copy going live and the original being paused, both ad sets can deliver for a short window. If Meta refuses to pause the original, both keep spending until someone pauses it by hand.
- If I choose to run both, the original is not paused and my total budget on that audience doubles.
I understand: new ad set IDs, the learning phase restarts, the originals are paused unless I choose to run both, and there can be a window of double spend.

SHA-256: 0168f7ab61559b4e1b8d6b8b1456d19cf58f546c7d4a4a6a7be4375a0a1dadff

Consent text · es · 2026-09-23.duplicate-swap.v1
Meta bloquea la configuración de conversión de un conjunto de anuncios una vez publicado, así que no se puede cambiar en su lugar. Para que estos conjuntos optimicen hacia la nueva conversión, Atribu creará una copia de cada uno y activará la copia. Esto es exactamente lo que significa:
- Cada copia es un conjunto nuevo con un ID nuevo. Conserva el mismo presupuesto, público, calendario y anuncios, pero sus resultados empiezan de cero y no se unen al historial del original en el Administrador de anuncios.
- La fase de aprendizaje de Meta vuelve a empezar en cada copia (normalmente unas 50 conversiones para estabilizarse), así que la entrega puede fluctuar durante algunos días.
- La copia se crea en pausa y se activa solo cuando todos sus anuncios están dentro. Después el original se pausa, nunca se elimina, y se puede reactivar en el Administrador de anuncios en cualquier momento.
- Entre que la copia se activa y el original se pausa, los dos conjuntos pueden entregar durante un periodo breve. Si Meta rechaza pausar el original, los dos siguen gastando hasta que alguien lo pause a mano.
- Si elijo correr los dos, el original no se pausa y mi presupuesto total en ese público se duplica.
Entiendo: IDs nuevos de conjuntos, la fase de aprendizaje se reinicia, los originales se pausan salvo que elija correr los dos, y puede haber un periodo de doble gasto.

SHA-256: 7e5d0f63d2baac8b33b9f6a7f7a31b74b0121a15654d89b9dce674e29f983a5c

The hash covers the text between the fences: lines joined with a single \n, no trailing newline.

AnswerMeaning
409 consent_requiredThe consent was missing, named another version, hashed another text, or is older than 24 hours. error.consent carries the current version and text_hashes. Nothing was read from or sent to Meta. When the version changes, re-render the new text and ask again: consent given to an old text does not cover a new one.
422 validation_errorconsent is not an object of four non-empty strings, accepted_at is not a timestamp, or it lies more than 5 minutes in the future.
200Per-ad-set outcome. recreated / duplicated carry the new ad set ids, and warnings states what the rebuild cost (below).

warnings (both routes, empty when nothing was rebuilt), each with the original ad_set_ids it concerns. Show them to the merchant:

CodeMeaning
new_ad_set_idsEvery rebuilt ad set now runs under a new id; its reporting starts from zero. Update any id you store.
learning_resetMeta's learning phase restarted on each copy (about 50 conversions).
double_spend_windowThe copy went live before the original was paused, so both could deliver briefly. The original is now paused.
double_spend_riskMeta refused to pause the original: both are spending now. The merchant must pause the original id in Ads Manager.
budget_doubledrun_both was chosen: the original keeps running beside its copy.

A signed-in Atribu session needs no consent object: the dashboard shows the same consequences in its own consent panel. On the repoint route, the automated rebuild applies only to a profile whose platform_safe_target is clean; under existing a key or MCP token gets 409 invalid_state naming the switch_to_clean_dataset remediation (POST /api/v1/conversion-sync/platform-safe/target with {"target": "clean"}), because the ad sets already receive the scrubbed feed and there is nothing to rebuild. The guided steps in the wiring read's ad_sets_to_move stay available as the manual alternative.

Acting on a merchant's ads

Four routes let your app act on — and explain — a merchant's Meta ads with the merchant's delegated key. None needs an open recommendation.

POST /api/v1/ads/{id}/pause           campaigns:apply   tier confirm
POST /api/v1/ads/{id}/resume          campaigns:apply   tier confirm
POST /api/v1/adsets/{id}/budget       campaigns:apply   tier money, ±50 %
GET  /api/v1/conversions/{id}/journey conversions:read

{id} on the three writes is Meta's own id — the platform_id of a GET /api/v1/campaigns?level=ad|ad_set row, the same id recommendation.target.ad_id / target.adset_id carry. The writes are the same action-layer writes POST /api/v1/recommendations/{id}/apply performs: one audit row in meta_action_log per attempt on a resolved connection (refusals from that point on included — the read-only-grant refusal, the guardrail, a Meta error), the Meta state captured before the write, and an undo through POST /api/v1/conversion-sync/actions/{action_id}/rollback (or, for a pause, the resume route). A rollback restores the row's pre-state only while the object still carries what that row wrote; after a later change it answers not_reversible with reason_code: "state_drifted" and writes nothing — roll back the latest change instead. Like the recommendation apply, campaigns:apply is entitlement-gated: the profile needs the atribu_attribution Add-on.

  • Idempotency-Key is required. Send one per logical change (your execution id works). The same key with the same request replays the first response for 24 hours without calling Meta, not even to read. The same key with a different request is 409 idempotency_key_conflict; a retry that arrives while the first is still running is 409 idempotency_key_in_flight (back off and retry). A key is per route: using one value for a pause and then a resume is two changes.
  • Refused before Meta is touched: an ad or ad set that is not this profile's (404), no usable Meta Ads connection (409 meta_connection_required), and a connection whose recorded grant lacks ads_management (403 meta_write_permission_missing — the merchant must reconnect Meta and accept "manage advertisements"; no retry helps).
  • Pause and resume need no person (tier confirm: the POST is the confirmation). Pausing a paused ad, or resuming an active one, is a 200 with outcome: "no_change" and no Meta write. Both refuse an ARCHIVED or DELETED ad (409 invalid_state) without writing — Meta would otherwise un-archive it. A Meta throttle arrives as 429 rate_limit_exceeded with Retry-After.
  • A budget change needs a person on the record (tier money). Your key does not have to name one: on a key minted by the app that provisioned this profile, applied_by defaults to the workspace's owner — the user POST /api/v1/profiles created for the merchant — as long as that user is still its active owner. The same default now applies to POST /api/v1/recommendations/{id}/apply, so you no longer look that id up through GET /api/v1/workspaces/{id}/members. An explicit applied_by is still accepted and must be an active owner or admin; from a signed-in session it must be that person unless they are an owner or admin themselves. Every response says which it was: applied_by_basis is applied_by, person or app_shadow_owner (on the recommendation apply: applied_by or app_shadow_owner).
  • The budget guardrail is ±50 % of the live budget per call (read from Meta at apply time) with Atribu's floor of 100 minor units. Outside it: 422 budget_change_out_of_bounds, and nothing reaches Meta. Meta's own per-account minimum is higher in most currencies (in CLP, about 1,000 pesos) and Atribu does not read it: a budget between the two is attempted and Meta's refusal comes back as 422 validation_error. An ad set whose campaign owns the budget, or on a lifetime budget, is 409 invalid_state. new_daily_budget_minor is in the ad account's minor units — for CLP that is pesos.

GET /api/v1/conversions/{id}/journey answers the visitor timeline behind one conversion — the same page as GET /api/v1/customers/{id}/journey — addressed by the outcome_event_id your POST /api/v1/events response returned, a conversions.id, or your own event key (the Idempotency-Key you ingested with, URL-encoded). With customers:read, meta.conversion.customer_profile_id names the customer it resolved to, and a conversion ingested with no identity at all answers 409 invalid_state. Without it, the answer is the projection described in Journey reads without customers:read, keyed by your customer_key.

Pausing a whole campaign (#1776)

GET  /api/v1/campaigns/{id}/status    campaigns:read
POST /api/v1/campaigns/{id}/pause     campaigns:apply   tier confirm
POST /api/v1/campaigns/{id}/resume    campaigns:apply   tier confirm

{id} is Meta's campaign id — the platform_id of a GET /api/v1/campaigns row. A pause is one write of the campaign's own status, never a fan-out over its ads, so there is no "7 of 9 paused" state to reconcile and one undo.

Render the consent screen from the status read. One call answers everything a «Pausar «{campaign}» · detiene n anuncios · ~$X al día» screen needs: campaign_name, status / effective_status, delivering_ads_count (the ads a pause would stop: delivering, or in processing, review or with issues), daily_budget_minor or, for a lifetime-budget campaign, lifetime_budget_minor (with minor_per_major), avg_daily_spend (an exact decimal string over the last spend_window_days complete days — fewer than 7 for a campaign that started spending more recently), and per action { available, reason, affected_ads_count }. Three Meta reads run in parallel under one ~5 s deadline; a count Meta does not answer is null while the status stays live. It never dead-ends on a campaign of this profile: with no connection, a dead token or a Meta outage it answers 200 with status_source: "synced" and both actions unavailable, each with a reason (meta_connection_missing, meta_reconnect_required, meta_unavailable). A read-only grant reads live and marks the actions ads_management_not_granted. last_pause names the Atribu pause in effect, and only while the campaign is paused.

Derive your Idempotency-Key from confirm_token. The status read's confirm_token changes whenever the campaign's status or Atribu's ledger for it changes. A key such as pause:{id}:{confirm_token} makes a retry of the same decision replay safely, while a later toggle (pause → resume → pause, the same day) always gets a new key and can never replay the earlier change. A replayed answer says outcome: "replayed".

The pause answer is the undo handle. It carries previous_status, status, affected_ads_count, the ledger action_id, and undo: { method: "POST", path: ".../resume" } — send that for «Reanudar» with a fresh random Idempotency-Key per click (a UUID), not one derived from the confirm_token you paused with: the pause changed the state, so that token is already gone. Both writes are idempotent on the end state: pausing a paused campaign and resuming an active one are 200 outcome: "no_change" with no write, so a stale button renders the end state instead of an error.

Resume undoes Atribu's pause. When the campaign was paused through Atribu, the resume claims that pause atomically and runs its rollback: the new ledger row carries rollback_of = <pause action_id> and the pause row becomes rolled_back (GET /api/v1/conversion-sync/actions shows both). Of two concurrent resumes only one undoes; the other re-reads the live status and answers from it — no_change once the campaign is active, or it resumes the campaign itself if the first write failed. A newer pause supersedes older ones, so a resume always undoes the newest. A campaign paused in Ads Manager is resumed as a plain campaign_resume. Ads paused on their own stay paused after a campaign resume — only the campaign's status is written, which is Meta's own semantics; affected_ads_count on a resume counts the ads the campaign pause was holding (Meta does not document whether a disapproved ad under a paused campaign is among them, so the count can include ads that will not deliver).

Refusals. A refusal specific to the campaign has its own code; reason only refines the shared class codes:

codereasonwhen
campaign_not_found (404)—not this profile's campaign (or a malformed id)
campaign_not_pausable / campaign_not_resumable (409)—ARCHIVED or DELETED — never written
meta_connection_required (409)—no usable Meta Ads connection
meta_write_permission_missing (403)—the merchant granted read-only access
unauthorized (401)meta_reconnect_requiredthe Meta token is dead (reconnect_required: true)
forbidden (403)meta_permission_denied / applied_by_not_allowedMeta refused for a missing permission / your applied_by is not an active owner or admin
rate_limit_exceeded (429)meta_rate_limitedMeta throttled the ad account (Retry-After)
provider_error (502) / invalid_request (422)meta_refusedany other Meta refusal; Meta's code is in message

Over MCP the same pair is pause_campaign / resume_campaign: preview is the status read (and returns confirm_token), confirm the write.

When Atribu answers "unknown" instead of failing (#1550)

Atribu's analytics store is moving into its own database. A handful of partner endpoints read one field from it while everything else on the same response comes from the messaging core — so when it is briefly unreachable, those endpoints answer 2xx with the field marked rather than erroring. Each races its analytics half against a 2-second budget.

EndpointMarkerWhat is unknown while it is set
GET /api/v1/connections, GET /api/v1/connections/{id}spend_unavailable: truedelivers_spend is null — unknown, not not applicable
GET /api/v1/conversationsenrichment_unavailable: truead_name, campaign_name are null; linked_customer_is_payer used email/phone only
GET /api/v1/profile, PATCH /api/v1/profileanalytics: "unavailable"has_subscription_revenue is its default, false
GET /api/v1/profile/readinessanalytics: "unavailable" + per-step evidenceevery step with evidence: "unavailable"
POST /api/v1/partner/wa-joinsattribution_deferred: trueonly the outcome event is owed — the join itself is recorded

Three rules for a consumer:

  1. Do not degrade a channel on spend_unavailable. A connection's status, status_reason, identity and sync counters are core-side and are exactly as trustworthy on such a response as on any other. Re-read delivers_spend later and keep whatever you last knew.
  2. A readiness step you could not measure is degraded, never missing. missing would tell a merchant to install a tracker they may already have installed. summary.done on such a response is a floor, not a verdict, and the step's next is to retry the same call.
  3. Do not retry on attribution_deferred. The join is already recorded and the outcome event is replayed automatically, on the same key — a retry re-parks the same work and buys nothing.

Which action_source to send

POST /api/v1/events takes an optional action_source in Meta's vocabulary. Atribu sends it to Meta verbatim on every Conversions API export of the event, in every privacy mode, and any value other than website goes out without event_source_url — not even the domain — even when the buyer has fbc/fbp browser signals (they still ride as match keys). Send the place the outcome really happened:

The outcomeaction_sourceAlso send
Born in a WhatsApp / Messenger / Instagram conversation (a booking, a quote accepted, a sale closed in the chat)business_messagingclick_ids.ctwa_clid when the chat started from a click-to-WhatsApp ad; the buyer's user_traits.phone
A payment taken in person (counter, card terminal, cash)physical_storeemail/phone
A fact your system derived (appointment confirmed, status changed, a scheduled job)system_generatedemail/phone
Something the buyer did on your website (a form the tag saw, a checkout)websiteanonymous_id, click_ids.fbclid
Phone call, email, your own appphone_call, email, appemail/phone

How business_messaging is exported:

  • With a ctwa_clid and a WhatsApp dataset, the event goes to the dataset linked to the profile's WhatsApp Business Account (one per WABA, created or fetched with POST /{waba_id}/dataset when the merchant connects WhatsApp and Meta Ads) with messaging_channel: "whatsapp". That is the dataset Meta attributes click-to-WhatsApp conversions against, and the one a "purchases through messaging" ad set optimizes on. This also holds for a platform_safe profile.
  • Without a click id, or while the merchant's WhatsApp connection is disconnected, it is sent to the website dataset as chat: Meta rejects a business_messaging event without a messaging click id (and the whole batch with it). The delivery's filters then read action_source_degraded:business_messaging_to_chat.

Only the top-level field counts: properties.action_source is ignored. A value outside the list is 400 invalid_parameter. Omit the field to keep the previous inference (business_messaging on a WhatsApp dataset with a click id, else website when the event has browser signals, else system_generated, with the page URL). Every entry of GET /api/v1/exports/ledger carries the action_source its payload actually had.

POST /api/v1/partner/wa-joins

Joins an inbound WhatsApp message to the web session that produced it — the read side of the web→WhatsApp attribution path. Its external_id hashes facts about the message (merchant phone, sender phone, timestamp, normalized text), all of which a retry resends verbatim, so a redelivery is the same conversation rather than a second one.

POST /api/v1/partner/declared-source

Records a customer's answer to "how did you find us?" when your assistant asked it inside your WhatsApp conversation and interpreted the reply. Atribu stores it in the same declared-source store the Shopify survey and Atribu's own WhatsApp prompt write to (origin = whatsapp, collected_via = partner), and it shows up in GET /api/v1/attribution/declared-source beside click attribution. A declared source is never a touch and never changes credit.

Scoping. There is no profile_id — the key is the tenant, exactly as on wa-joins. merchant_phone and/or waba_id must name one of that profile's WhatsApp accounts (404 otherwise), and that account must have the question switched on with PATCH /api/v1/whatsapp/declared-source-prompt (403 otherwise): an answer is personal data, collected only for a purpose the business turned on.

The customer is exactly one of two forms.

  • { "external_ref": "…", "email"?: "…", "phone"?: "…" } — your own customer id, the same value you send as user_traits.external_id on POST /api/v1/events and read back as customer_key.
    • A ref Atribu already knows resolves read-only. An email / phone sent with it is recorded on that customer; one another customer already holds stays with that customer.
    • An unknown ref with an email and/or phone is how you backfill answers for people you never sent: Atribu resolves or creates the customer from that PII and binds the ref, exactly as POST /api/v1/events does for user_traits.external_id — so the ref alone resolves on your next call.
    • An unknown ref with no PII is 422 unknown_customer_ref. Atribu never creates a customer from a ref alone.
  • { "wa_id": "569…" } — the customer's WhatsApp number. Atribu resolves or creates the customer from the phone.

Both forms work on a healthcare-family profile (a clinic). A national id (RUT) is never accepted: there is no national_id field, and a national_id / rut key or a RUT-shaped external_ref / phone is 400 invalid_parameter.

{
  "waba_id": "1435251738018078",
  "customer": { "external_ref": "vitrina-patient-8f2c" },
  "answer_key": "walk_in",
  "answer_text": "pasé por fuera de la clínica",
  "asked_at": "2026-09-25T12:00:00-03:00",
  "answered_at": "2026-09-25T12:01:30-03:00",
  "wamid": "wamid.HBgLNTY5MjIyMjIyMjIVAgASGBQzQTk4QjE="
}

Backfilling a customer Atribu has never seen:

{
  "waba_id": "1435251738018078",
  "customer": {
    "external_ref": "vitrina-patient-a91d",
    "email": "[email protected]",
    "phone": "+56944444444"
  },
  "answer_key": "instagram",
  "answered_at": "2026-09-20T18:42:05-03:00",
  "wamid": "wamid.HBgLNTY5NDQ0NDQ0NDQVAgASGBQ3RkQ2QzE="
}
{
  "merchant_phone": "+56911111111",
  "customer": { "wa_id": "56922222222" },
  "answer_key": "instagram",
  "answered_at": "2026-09-25T12:01:30-03:00",
  "wamid": "wamid.HBgLNTY5MjIyMjIyMjIVAgASGBQzQTk4QjI="
}

answer_key must be one of the keys below; anything else is 422 unknown_answer_key and nothing is written. Map the customer's words onto the closest key, or send other with their words in answer_text — the text is kept (up to 200 characters) only for other.

facebook · instagram · tiktok · youtube · google · other_social · influencer · friend_family · email · whatsapp · podcast · tv_radio · print_outdoor · store_event · walk_in · press_blog · ai_assistant · other

walk_in is "walked or drove past the place" — a customer who saw the premises from the street. store_event is a store, fair or event they attended.

Idempotent — retry it with the same body. The key is wamid, and WhatsApp's unit is the customer: one answer per customer per profile, first wins. A retry, a second message from the same customer, or a reply Atribu also captured itself all answer 200 with status: "already_answered" and the stored answer. 503 is transient; retry.

{
  "data": {
    "status": "recorded",
    "answer_id": "5b0c2f8e-3a1d-4e7b-9f10-2c4d6e8a0b1c",
    "answer_key": "walk_in",
    "customer_profile_id": "0f3e9a2b-7c41-4d58-8e6f-1a2b3c4d5e6f"
  },
  "meta": { "profile_id": "a4d1f0c2-3b5e-4d7a-9c11-2f6b8e0d4a33" }
}

POST /api/v1/partner/ad-referrals

Records a historical click-to-message ad referral, so that attribution knows which Meta ad a past WhatsApp, Instagram or Messenger conversation came from. It is for a business moving to Atribu from another bot, whose old system knows which ad each conversation started from but whose messages Atribu never received.

What one referral becomes: a touch, and nothing else.

  • A touch at occurred_at naming the ad. Atribu resolves the campaign and ad set from its own Meta ads sync. The touch is judged by the profile's own attribution windows, like any other click: a later conversion of that customer, such as a closed_won or a payment_received, is credited to it only inside them (30 days for last touch, 90 days for first touch, by default). A referral from February does not take credit for a payment in September. A customer with several referrals gets one touch per referral.
  • No lead. The call never creates a lead_created and never changes an existing lead, so the customer's next lead is recorded, and exported, exactly as it would have been. Leads from before Atribu was connected are not reconstructed.
  • Nothing from this call is sent to Meta. A touch is never exported, and the click id stays on the touch: it is not stored on the customer, so it never rides a later conversion of theirs to Meta.

Scoping and the customer work exactly as on POST /api/v1/partner/declared-source (above): the key is the tenant, merchant_phone / waba_id must name one of that profile's WhatsApp accounts (404 otherwise), and the customer is { external_ref, email?, phone? } or { wa_id }. To send someone Atribu has never seen, include their email and/or phone with your external_ref. A national id (RUT) is never accepted.

ad_id must be the numeric Meta ad id (referral.source_id) of one of the profile's own ads: one Atribu's Meta sync knows for the profile, or one with delivery (spend) in the profile's synced Meta insights. The second case covers ads archived or deleted before the account was connected, which the ad catalog never lists. An ad name is 400; any other id is 422 unknown_ad. ctwa_clid is accepted only with channel: "whatsapp". occurred_at is when the customer's first message from the ad arrived and must be in the past. There is no lower bound.

{
  "waba_id": "1435251738018078",
  "customer": {
    "external_ref": "legacy-bot-contact-8f2c",
    "phone": "+56944444444"
  },
  "channel": "whatsapp",
  "ad_id": "120212345678900001",
  "ctwa_clid": "ARAkLkA8rmlFeiCktEJQ",
  "headline": "Spring offer: 2 for 1",
  "occurred_at": "2026-03-14T10:22:05-03:00",
  "idempotency_key": "legacy-bot:conversation:8812"
}

Idempotent, so retry it with the same body. The key is idempotency_key. Within 24 hours a retry replays the first response; after that it answers status: "already_recorded". Either way nothing is written twice. The same key with a different customer, channel, ad or occurred_at is 409 idempotency_key_conflict, refused before anything is written — including before a new customer is created. (headline and ctwa_clid are not part of that comparison.) While a first request with that key is still running, another one is 409 idempotency_key_in_flight; retry it after a short wait. A first request that never answered (for example, one cut off by a restart) stops blocking its key after 5 minutes, and the next retry completes it. 503 is transient, so retry.

{
  "data": {
    "status": "recorded",
    "customer_profile_id": "0f3e9a2b-7c41-4d58-8e6f-1a2b3c4d5e6f",
    "touch_id": "7d2e1c0b-9a8f-4e6d-8c5b-4a3f2e1d0c9b"
  },
  "meta": { "profile_id": "a4d1f0c2-3b5e-4d7a-9c11-2f6b8e0d4a33" }
}

Preview before you apply: POST /api/v1/partner/ad-referrals/preview

Read-only. Send up to 500 referrals in the same shape (the merchant fields once, at the top) and Atribu reports how applying them would change credit, without creating a customer or writing anything. For every customer the batch names, it projects each existing conversion twice, as it is today and with the new touches, using the profile's own attribution windows.

{
  "waba_id": "1435251738018078",
  "referrals": [
    {
      "customer": { "wa_id": "56922222222" },
      "channel": "whatsapp",
      "ad_id": "120212345678900001",
      "occurred_at": "2026-03-14T10:22:05-03:00",
      "idempotency_key": "legacy-bot:conversation:8812"
    }
  ]
}
{
  "data": {
    "referrals": 500,
    "already_recorded": 0,
    "unknown_ads": 3,
    "existing_conversions": 640,
    "conversions_gaining_touch": 57,
    "credited_ad_changed": [
      { "model": "last_touch", "conversions": 21 },
      { "model": "first_touch", "conversions": 49 },
      { "model": "linear", "conversions": 57 },
      { "model": "time_decay", "conversions": 21 },
      { "model": "last_non_direct", "conversions": 21 },
      { "model": "atribu", "conversions": 30 }
    ],
    "windows": { "click_window_days": 30, "view_window_hours": 24, "first_touch_window_days": 90 },
    "samples": [
      {
        "conversion_id": "1b2c3d4e-5f60-4718-8293-a4b5c6d7e8f9",
        "conversion_type": "payment_received",
        "conversion_time": "2026-03-20T15:00:00.000Z",
        "model": "last_touch",
        "credited_ad_before": null,
        "credited_ad_after": "120212345678900001"
      }
    ]
  },
  "meta": { "profile_id": "a4d1f0c2-3b5e-4d7a-9c11-2f6b8e0d4a33" }
}

credited_ad_changed counts, per attribution model, the existing conversions whose credited ad (the one with the largest share of credit) would change. samples lists up to 50 of those changes. unknown_ads counts referrals whose ad_id is not one of the profile's Meta ads: the write would refuse each with 422 unknown_ad, so they are left out of every other number.

GET /api/v1/partner/whatsapp/ads/{ad_id}/welcome-message

The welcome message ("Saludo automático") configured on a click-to-WhatsApp ad: the greeting the customer was already shown, under the business's name, before they sent their first message — plus the pre-filled text or the ice-breaker buttons they most likely tapped instead of typing.

Meta does not deliver this over the webhook. The referral object on the customer's first inbound message carries the ad's headline, body and thumbnail; the greeting is ad configuration and is readable only from the Marketing API. Without it, an assistant composing the first reply greets someone who has already been greeted, and asks what it can help with after they have already said.

ad_id is referral.source_id from that first message. Like wa-joins, the credential is the tenant selection — there is no profile_id parameter, so a multi-tenant consumer must send the per-connection token that carries the right profile. connection_id is optional and only narrows: it must name a meta_ads connection on that same profile.

The answer is cached per (profile, ad) — 24h for an ad Meta answered for, 1h for one it refuses — so one popular ad does not turn one fact into one Graph call per lead. cached says which, and fetched_at is when the answer was read from Meta, not when you called.

An ad with no greeting configured answers 200 with welcome: null. That is a complete answer, not a failure. The three failures are deliberately distinct, because what to do about them differs: 404 (Meta does not show us the ad — deleted, or invisible to this token; Graph does not distinguish those, so neither do we), 409 (this profile has no usable Meta Ads connection — a human has to connect or reconnect), 502 (Meta failed transiently — retry).

GET /api/v1/partner/whatsapp/ads

Every click-to-WhatsApp ad on the profile: which ones open WhatsApp, the number and Page they open, the call-to-action they carry, and the greeting each shows before the customer types anything.

This is the question the welcome-message read above cannot answer. That one needs an ad_id, and the only way to learn one is a conversation that already happened. A screen showing a dealer their conversational ads — or an audit asking which of them greet and which do not — has no id to start from.

An ad is listed when WhatsApp is named either by its ad set's destination_type or by its creative's call-to-action. The two are set in different places by different people and an ad commonly carries only one: destination_type on an ad set built from the Messages objective, a WHATSAPP_MESSAGE call-to-action on a CTWA ad dropped into an existing traffic ad set. destination_type is Meta's string verbatim and Meta adds combined values (MESSAGING_INSTAGRAM_DIRECT_MESSENGER_WHATSAPP) without notice — treat it as an opaque string containing WHATSAPP, never an enum.

greeting_status is the field to branch on, not greeting === null:

valuemeaningwhat to do
presentthe ad has a greeting, in greetinguse it
nonethe creative was read and configures nonenothing — a normal ad, and a final answer
unreadableMeta would not show us the creative (deleted ad, a Page this token does not admin, a connection needing reconnection)fix the Meta connection, then ask again

This surface never calls Meta. It is served from Atribu's nightly structure sync, so polling it costs the advertiser's ads_management quota nothing — which is also why last_synced_at is on every row rather than left to be inferred. An ad launched since the last sync is not listed yet; its greeting is already readable by id on the welcome-message route, which falls back to a live Graph read for exactly that case.

Pagination is keyset on ad_id: pass pagination.cursor back as after. An offset would let a row the sync writes mid-walk shift the window, which shows up as a consumer silently skipping an ad.

There is no spend, no impression, no result and no customer data here — only the advertiser's own configuration, which is what makes it servable under a partner connection's whatsapp scope. Performance data stays behind campaigns:read on the ads routes that serve it.

MethodPathWhat it does
POST/api/v1/admin/oauth-appsCreate a consumer OAuth app
GET/api/v1/admin/oauth-apps/{id}Read a consumer OAuth app
PATCH/api/v1/admin/oauth-apps/{id}Update a consumer OAuth app
DELETE/api/v1/admin/oauth-apps/{id}Suspend a consumer OAuth app (kill-switch)
POST/api/v1/admin/oauth-apps/{id}/rotate-client-secretRotate the OAuth app's client_secret
POST/api/v1/admin/oauth-apps/{id}/rotate-jwt-secretRotate the OAuth app's id_token_hint HS256 signing secret
GET/api/v1/admin/oauth-apps/{id}/usageOne consumer app's API usage
GET/api/v1/admin/pii-grantsWho can read personal data through this API
GET/api/v1/admin/shopify-appsList registered Shopify apps
POST/api/v1/admin/shopify-appsRegister a merchant's custom-distribution Shopify app
PATCH/api/v1/admin/shopify-apps/{id}Update a Shopify app's OAuth scopes
DELETE/api/v1/admin/shopify-apps/{id}Revoke a Shopify app (kill-switch)
POST/api/v1/partner/ad-referralsRecord a historical click-to-message ad referral
POST/api/v1/partner/ad-referrals/previewPreview what a batch of historical ad referrals would re-credit
POST/api/v1/partner/declared-sourceRecord a customer's "how did you find us?" answer from your WhatsApp conversation
POST/api/v1/partner/wa-joinsJoin an inbound WhatsApp message to the web session that produced it
GET/api/v1/partner/whatsapp/adsList the profile's click-to-WhatsApp ads
GET/api/v1/partner/whatsapp/ads/{ad_id}/welcome-messageRead a click-to-WhatsApp ad's welcome message
POST/oauth/revokeRFC 7009 token revocation
POST/oauth/tokenOAuth 2.0 token endpoint

Generated from openapi.json. The full request and response schema for every operation is in the OpenAPI document.

Next steps

On this page