# Atribu API — Complete Documentation

> Marketing attribution API. Base URL: https://www.atribu.app/api/v1
> Auth: Bearer token (API key starting with atb_live_)

---

# The golden path

Atribu connects your ad platforms (Meta, Google Ads), your CRM (GoHighLevel),
your payment providers (Stripe, MercadoPago, Webpay) and your own systems, and
answers one question: **which ads produced real money**. Then it sends that
answer back to Meta so the ad platform can optimize on it.

Getting there is thirteen steps. This page is the map; every step has its own
page with **two tracks**:

- **In the app** — the screens, the exact menu path, what you will see.
- **With the API / an AI agent** — the `curl`, the SDK call, the MCP tool, and
  the hand-off pattern for the steps only a human can finish: *give the user
  this URL, poll this handle*.

<VideoEmbed src="/docs/videos/getting-started.mp4" title="Get started with Atribu in 5 steps" />

<Callout type="info" title="Is an AI agent setting this up for you?">
Then you have almost nothing to do. **Step 2 is yours and it is the only one:
your agent gives you a URL. Open it, create your account, approve. Come back.**
Everything before and after that is the agent's work — see the
[API quickstart](/docs/api/quickstart) or the
[MCP quickstart](/docs/mcp/quickstart) for what it is doing on your behalf.
</Callout>

<Callout type="info" title="Are you the agent?">
Start at [For AI agents](/docs/agents) — the bootstrap, the discovery
documents, the readiness read, and the hand-off pattern, in one page.
</Callout>

## The thirteen steps

| # | step | what has to be true at the end | readiness key |
|---|---|---|---|
| 1 | [Discover](/docs/getting-started/overview) | you know what Atribu does and which track you are on | — |
| 2 | [Create your account](/docs/getting-started/create-account) | an Atribu account exists, email confirmed | — |
| 3 | [Workspace and profile](/docs/getting-started/workspace-and-profile) | one workspace, one profile per advertised business | — |
| 4 | [Credentials](/docs/getting-started/credentials) | you hold a key or token that can act | — |
| 5 | [Install the tracker](/docs/getting-started/install-tracker) | an event reached Atribu in the last 7 days | `tracker_installed` |
| 6 | [Connect Meta and Google](/docs/getting-started/connect-ads) | a healthy ad-platform connection | `ad_platform_connected` |
| 7 | [Connect your CRM](/docs/getting-started/connect-crm) | something tells Atribu when a lead or sale happens | `crm_or_outcome_source_connected` |
| 8 | [Connect payments](/docs/getting-started/connect-payments) | cash can reach a conversion | `payments_connected` |
| 9 | [Define conversions](/docs/getting-started/define-conversions) | one `cash` + attribution-eligible definition exists | `conversion_definitions_valid`, `attribution_windows_set` |
| 10 | [Verify](/docs/getting-started/verify) | a conversion has been credited to a touch | `first_conversion_attributed` |
| 11 | [Send it back to Meta](/docs/getting-started/send-to-meta) | a destination, a rule, and a test event Meta received | `capi_destination_enabled`, `capi_rules_enabled` |
| 12 | [DPA and plan](/docs/getting-started/dpa-and-plan) | the DPA is accepted and a plan is in place | `legal_dpa`, `plan` |
| 13 | [What next](/docs/getting-started/what-next) | you find out when something breaks, without looking | — |

The **readiness key** column is not decoration. `GET /api/v1/profile/readiness`
returns exactly these steps, in this order, with a status and a next action for
each — so at any point you can ask the API where you are instead of guessing.
See [Readiness](/docs/api/readiness).

## Step 1 — Discover

You are here. Three things worth knowing before you spend the fifteen minutes:

- **Only cash counts for ROAS.** Atribu deliberately keeps pipeline value
  (a CRM's projected deal value) separate from money that actually arrived.
  Mixing them makes every ratio meaningless. See
  [Revenue types](/docs/concepts/revenue-types).
- **Attribution needs both halves.** Ad platforms know the clicks; your CRM and
  payment provider know the money. Neither alone can answer the question, which
  is why steps 6, 7 and 8 all exist.
- **Nothing here is a black box.** Five attribution models, per-profile windows,
  and a coverage number that tells you what *cannot* be attributed. See
  [Attribution models](/docs/concepts/attribution-models).

Machine-readable entry points, if you are a program:
[`llms.txt`](https://www.atribu.app/llms.txt),
[`llms-full.txt`](https://www.atribu.app/llms-full.txt),
[the OpenAPI document](https://www.atribu.app/api/v1/openapi.json), and the MCP
endpoint at `https://mcp.atribu.app/mcp`.

## How long it takes

About fifteen minutes of clicking, then a wait. Spend and tracker data appear
within minutes; attributed revenue appears when your first real conversion
lands, which is your customers' clock rather than ours.

## Next steps

<Cards>
  <Card title="Step 2 — Create your account" href="/docs/getting-started/create-account">
    Sign up, or open the URL your agent handed you
  </Card>
  <Card title="For AI agents" href="/docs/agents">
    The bootstrap, the discovery documents, the hand-off pattern
  </Card>
  <Card title="Readiness" href="/docs/api/readiness">
    Ask the API where you are, at any point
  </Card>
</Cards>

---

# Step 2 — Create your account

<Callout type="warn" title="There is no signup API, and that is deliberate">
Account creation is a human act behind a captcha. Every other step below can be
driven by a program; this one cannot, whichever track you are on.
</Callout>

<Tabs items={["In the app", "With the API / an AI agent"]}>
<Tab value="In the app">

1. Go to [atribu.app/signup](https://www.atribu.app/signup).
2. Enter your email and a password, and clear the Turnstile challenge.
3. Confirm your email from the link we send you.

You land in the console with no workspace yet — that is
[step 3](/docs/getting-started/workspace-and-profile).

Free is a real plan, not a trial: `$0`, one ad account, no card. Growth and
Agency are self-serve from the same signup
(`/signup?plan=growth`, `/signup?plan=agency`); Enterprise talks to us first.

</Tab>
<Tab value="With the API / an AI agent">

**The bootstrap is the OAuth hand-off**, exactly like connecting Gmail or
Cloudflare. Your agent registers a client, builds an authorization URL, and
hands that URL to its human — who creates the account **inside** that hand-off
if they do not have one.

```text title="What your agent says"
Open this to connect your Atribu account:
https://www.atribu.app/oauth/mcp/authorize?response_type=code&client_id=…
```

What the human sees, whether or not they already have an account:

1. **No account yet** — the page sends them to sign in; an unrecognised email
   goes straight to signup with the pending authorization preserved. They
   confirm their email, and the confirmation link brings them **back to the
   consent screen**, not to a dashboard.
2. **The consent screen** — the agent's name, the scopes it asked for as
   individual checkboxes, and the host its redirect points at. A user with no
   workspace is told so plainly: *"You have no workspace yet — your agent will
   create one after you approve."*
3. **Approve** — the browser is redirected back to the agent with `code` and
   `state`.

The full sequence — dynamic client registration, PKCE, the token exchange, the
refresh rotation — is the [API quickstart](/docs/api/quickstart). Do not
duplicate it here; that page is the contract.

<Callout type="info" title="Come back when done">
This is the whole hand-off pattern in miniature: hand over a URL, wait for a
signal, continue. Steps 6, 7, 8, 11 and 12 use the same shape with an explicit
object you can poll — see [Hand-offs](/docs/api/handoffs).
</Callout>

</Tab>
</Tabs>

## What you have at the end

An Atribu account with a confirmed email — and, on the agent track, an
`atb_user_…` token that works on **both** the MCP endpoint and `/api/v1`.

## Next steps

<Cards>
  <Card title="Step 3 — Workspace and profile" href="/docs/getting-started/workspace-and-profile">
    The container and the business being measured
  </Card>
  <Card title="API quickstart" href="/docs/api/quickstart">
    The OAuth bootstrap in full
  </Card>
  <Card title="MCP quickstart" href="/docs/mcp/quickstart">
    The same bootstrap, from an MCP client
  </Card>
</Cards>

---

# Step 3 — Workspace and profile

A **workspace** is the container: billing, members, branding, API keys. A
**profile** is one advertised business inside it — one set of ad accounts, one
CRM, one tracker, one attribution answer.

Most businesses need one workspace and one profile. An agency needs one
workspace and a profile per client.

<Callout type="warn" title="Every later step is per profile">
Connections, the tracker, conversion definitions, attribution windows, the Meta
destination, readiness — all of it belongs to a *profile*, not to the
workspace. Creating the second profile means doing steps 5 to 12 again for it.
</Callout>

<Tabs items={["In the app", "With the API / an AI agent"]}>
<Tab value="In the app">

<Steps>
<Step>

### Create the workspace

On your first sign-in you are asked for a workspace name — your company, or
your agency. One is usually enough.

</Step>
<Step>

### Create the profile

Then a profile: the business you are actually going to measure. Give it the
brand's name, and set its **reporting currency** — every money figure on the
profile is rendered in it.

</Step>
<Step>

### Add more later

**Profiles → New profile** for the next client. A profile that would exceed
your plan's active-profile allowance is refused with a clear message rather
than created and quietly disabled.

</Step>
</Steps>

</Tab>
<Tab value="With the API / an AI agent">

```bash title="Create the workspace"
curl -sX POST https://api.atribu.app/api/v1/workspaces \
  -H "Authorization: Bearer atb_user_…" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme", "timezone": "America/Santiago", "plan": "starter"}'
```

Since #1085 the `atb_user_…` token you got in step 2 is accepted here directly,
and the call is **idempotent on `(you, name)`** for a short window — a retry
after a dropped connection returns the same workspace with `created: false`
rather than a duplicate. `plan` defaults to `starter` (Free); `growth` and
`agency` write a `trialing` subscription at that plan's real limits
immediately, so an agent that asked for Growth is not silently left on Free.

```bash title="Create the profile"
curl -sX POST https://api.atribu.app/api/v1/profiles \
  -H "Authorization: Bearer atb_user_…" \
  -H "Content-Type: application/json" \
  -d '{"workspace_id": "…", "name": "Acme Chile", "currency": "CLP"}'
```

Then read back what you can reach — this is the call to make **first** on every
run, because a user token may span several workspaces:

```bash title="What does this token reach?"
curl -H "Authorization: Bearer atb_user_…" \
  https://api.atribu.app/api/v1/workspaces
```

Over MCP the equivalents are `whoami`, `list_workspaces` and `list_profiles`.
`whoami` costs zero units and answers with the workspaces, profiles, currency,
PII mode and write-back state the caller actually has — call it first, always,
rather than guessing.

<Callout type="info" title="Over MCP">
`create_workspace` and `create_profile` are these two calls as tools (#1058),
each with a free `mode: "preview"` before `mode: "confirm"`. An MCP-only agent
needs no REST call and no browser for this step.
</Callout>

<Callout type="warn" title="A user token must name a profile">
An API key *is* one profile, so profile-scoped routes need no parameter. An
`atb_user_…` token may reach several, so `?profile_id=<uuid>` is **required** on
those routes — its absence is a `400`, never a guess.
</Callout>

</Tab>
</Tabs>

## Plan limits

`active_profiles` is the one plan limit enforced on every profile-creation path.
Read it before planning a batch:

```bash
curl -H "Authorization: Bearer …" \
  https://api.atribu.app/api/v1/workspaces/{workspaceId}/subscription
```

`plan_limit_reached` on a create is the answer to *upgrade first*, and
[step 12](/docs/getting-started/dpa-and-plan) is where that happens.

## Next steps

<Cards>
  <Card title="Step 4 — Credentials" href="/docs/getting-started/credentials">
    A key or token that can act on this profile
  </Card>
  <Card title="Workspaces & profiles API" href="/docs/api/workspaces">
    Every route under /workspaces and /profiles
  </Card>
  <Card title="Team and roles" href="/docs/settings/team">
    Inviting people, and what each role can do
  </Card>
</Cards>

---

# Step 4 — Credentials

Three credential classes reach Atribu, and picking the wrong one is the most
common way a first integration stalls at a `403`.

| credential | who it is | when to use it |
|---|---|---|
| `atb_user_…` **MCP / user token** | a **person** | an agent acting on someone's behalf, on MCP *and* REST |
| `atb_live_…` **API key** | one **profile** | a machine job: a nightly export, a CI check, a webhook consumer |
| **session** | the console | Atribu's own UI |

<Tabs items={["In the app", "With the API / an AI agent"]}>
<Tab value="In the app">

<Steps>
<Step>

### Open Settings → Developer

The Developer tab lists this profile's API keys — prefixes only, never the
secret.

</Step>
<Step>

### Create an API key

Pick the scopes it needs. Default is the analytics read set; add
`goals:write`, `exports:write` or a messaging scope only if the job actually
does that.

</Step>
<Step>

### Copy it now

It starts with `atb_live_` and is shown **exactly once**. Store it in your
secret manager before leaving the page.

</Step>
</Steps>

MCP tokens live under the same Developer tab, or are minted for you by the
OAuth consent in [step 2](/docs/getting-started/create-account).

</Tab>
<Tab value="With the API / an AI agent">

**If you completed step 2 through the OAuth hand-off, you are done.** Since
#1084 an `atb_user_…` token is a first-class `/api/v1` principal, so the token
you already hold works on both surfaces:

```bash title="MCP"
curl -sX POST https://mcp.atribu.app/mcp \
  -H "Authorization: Bearer atb_user_…" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"whoami","arguments":{}}}'
```

```bash title="REST — same token"
curl -H "Authorization: Bearer atb_user_…" \
  https://api.atribu.app/api/v1/workspaces
```

<Callout type="warn" title="A credential cannot administer its own class">
`/api/v1/me/**` and the workspace API-key routes are **session bearers only**.
An API key is one profile — not a person, with no MCP credentials of its own —
so it can never mint, rotate or revoke a key or a token. Minting stays a
signed-in human's act, and an agent that needs a key hands its user a link to
**Settings → Developer**.
</Callout>

Two more rules that decide what a `403` means:

- **Scopes are an intersection** of what the human granted on the consent
  screen and what their membership already allows. `mcp:read` maps to
  `analytics:read` + `campaigns:read`; no MCP scope reaches exports, commerce
  or messaging.
- **`insufficient_scope` names the scope** it wanted, and carries a
  `docs_url` — see [Errors](/docs/api/errors).

</Tab>
</Tabs>

## Rotating and revoking

`…/api-keys/{id}/rotate` issues a replacement and returns the new secret;
`…/revoke` kills one outright. Rotation is the right move for a key that may
have leaked: revoking alone leaves whatever used it broken until someone
notices.

## Next steps

<Cards>
  <Card title="Step 5 — Install the tracker" href="/docs/getting-started/install-tracker">
    The first thing that produces data
  </Card>
  <Card title="Credentials API" href="/docs/api/credentials">
    Every route under /me, and the key routes
  </Card>
  <Card title="Authentication" href="/docs/api/authentication">
    The scope catalogue and rate limits
  </Card>
</Cards>

---

# Step 5 — Install the tracker

This page is the **In the app** track for step 5 of the
[golden path](/docs/getting-started/overview). The agent track is at the
bottom, under *With the API / an AI agent*.

The Atribu tracking script is a lightweight JavaScript snippet that runs on your website. It captures:

- **Page views** -- which pages visitors see and how long they stay
- **Visitor identity** -- assigns each device a unique anonymous ID so you can follow the journey across visits
- **Traffic sources** -- reads UTM parameters and click IDs (fbclid, gclid) from your ad URLs to know which campaign brought each visitor
- **Form submissions** -- automatically detects when someone fills out a form and extracts their email or phone number
- **Booking completions** -- detects GoHighLevel, Calendly, and Cal.com booking widgets

## Installation

Go to **Settings > Tracking** in your Atribu dashboard. You will see your personalized tracking snippet with your unique tracking key already filled in. Copy it and install it using one of the methods below.

<Tabs items={["HTML (copy-paste)", "Google Tag Manager", "Shopify"]}>
<Tab value="HTML (copy-paste)">

Paste the snippet into the `<head>` section of every page on your site. If you use a CMS like WordPress, Webflow, or Squarespace, look for the "Custom Code" or "Header Code Injection" setting.

```html title="Tracking snippet"
<script>
  window.ATRIBU_TRACKING_KEY = "your-tracking-key-here";
  window.ATRIBU_TRACKING_ENDPOINT = "https://www.atribu.app/api/tracking/collect";
  window.ATRIBU_INTERCEPT_FBQ = true;
  window.ATRIBU_META_BRIDGE_PAGEVIEW = false;
  window.atribuTracker = window.atribuTracker || function() {
    (window.atribuTracker.q = window.atribuTracker.q || []).push(arguments);
  };
</script>
<script src="https://www.atribu.app/atribu-tracker.js" defer></script>
```

<Callout type="info" title="Use your actual snippet">
The code above is a template. Copy your real snippet from **Settings > Tracking** -- it has your tracking key pre-filled.
</Callout>

The `defer` attribute ensures the script loads without slowing down your page. Events that happen before the script finishes loading are queued and sent once it is ready.

</Tab>
<Tab value="Google Tag Manager">

<Steps>
<Step>

### Create a new tag

In Google Tag Manager, go to **Tags > New** and select **Custom HTML** as the tag type.

</Step>
<Step>

### Paste the snippet

Copy your tracking snippet from **Settings > Tracking** in Atribu and paste it into the HTML field.

```html title="Custom HTML tag content"
<script>
  window.ATRIBU_TRACKING_KEY = "your-tracking-key-here";
  window.ATRIBU_TRACKING_ENDPOINT = "https://www.atribu.app/api/tracking/collect";
  window.ATRIBU_INTERCEPT_FBQ = true;
  window.ATRIBU_META_BRIDGE_PAGEVIEW = false;
  window.atribuTracker = window.atribuTracker || function() {
    (window.atribuTracker.q = window.atribuTracker.q || []).push(arguments);
  };
</script>
<script src="https://www.atribu.app/atribu-tracker.js" defer></script>
```

<Callout type="info" title="Just want a single tag?">
GTM's Custom HTML tag re-creates `<script>` elements when it renders them, and it only copies over `id`, `text`, `charset`, `type`, and `src` -- any `data-*` attribute is silently dropped. The two-tag snippet above sidesteps that by setting `window.ATRIBU_TRACKING_KEY` in an inline script first, but if you'd rather paste one tag, put the key in the `src` query string instead:

```html title="Single-tag alternative"
<script src="https://www.atribu.app/atribu-tracker.js?key=your-tracking-key-here"></script>
```

Add `&endpoint=...` to the same query string to override `ATRIBU_TRACKING_ENDPOINT` too. A `window.ATRIBU_TRACKING_KEY` set anywhere on the page always takes precedence over the query string, so the two forms can coexist safely.
</Callout>

</Step>
<Step>

### Set the trigger

Set the trigger to **All Pages** so the tracker loads on every page of your site.

</Step>
<Step>

### Publish

Click **Submit** to publish the container. The tracker will start collecting data immediately.

</Step>
</Steps>

</Tab>
<Tab value="Shopify">

Shopify's Web Pixel system provides a sandboxed environment for third-party scripts.

<Steps>
<Step>

### Go to Customer Events

In your Shopify admin, navigate to **Settings > Customer Events**.

</Step>
<Step>

### Add a custom pixel

Click **Add custom pixel** and give it a name like "Atribu Tracker".

</Step>
<Step>

### Paste the snippet

Copy your tracking snippet from **Settings > Tracking** in Atribu and paste it into the code editor.

</Step>
<Step>

### Save and connect

Click **Save**, then toggle the pixel to **Connected**.

</Step>
</Steps>

<Callout type="warn" title="Shopify limitations">
Shopify's Web Pixel runs in a sandboxed iframe. Form auto-capture and booking widget detection may not work inside the sandbox. For full functionality, consider adding the tracker via the theme's `theme.liquid` file instead (paste it before the closing `</head>` tag).
</Callout>

</Tab>
</Tabs>

## How the tracker works

<Callout type="info" title="IDs and cookies">
The tracker creates two identifiers for each visitor:

- **Anonymous ID** (`atribu_visitor_id` cookie) -- A unique ID for each device/browser. Persists for 1 year. This is how Atribu counts unique visitors.
- **Session ID** (`atribu_session_id` cookie) -- A unique ID for each visit. Resets after 30 minutes of inactivity or when the visitor arrives from a new marketing source (different UTM parameters or click ID). This is how Atribu counts sessions.

Both IDs are stored as first-party cookies so server-side code (like checkout pages) can read them and pass them to payment providers for better attribution.
</Callout>

### Automatic features

The tracker automatically captures these events without any extra code:

| Feature | What it does |
|---------|-------------|
| Page views | Tracks every page the visitor sees, including single-page app navigation |
| Form submissions | Detects form submissions, extracts email/phone, and links the anonymous visitor to a known customer |
| Booking widgets | Detects GoHighLevel, Calendly, and Cal.com booking completions |
| Stripe checkout redirects | Detects `?session_id=cs_*` in the URL after a Stripe Payment Link redirect |
| Outbound link clicks | Tracks clicks to external domains |
| File downloads | Tracks downloads (PDF, ZIP, etc.) |
| Engagement | Measures scroll depth and time on page |

### The identify() call

The most important thing the tracker does is **link anonymous visitors to known customers**. When someone fills out a form on your site, the tracker automatically calls `identify()` with their email or phone number. This creates a connection between the anonymous browser visitor and the customer record in your CRM or payment provider.

Without this link, Atribu cannot attribute a Stripe payment back to the original ad click. Form auto-capture handles this automatically for standard HTML forms. If you use a custom form (React, Vue, etc.), you can call `identify()` manually:

```js title="Manual identify call"
window.atribuTracker.identify({
  email: "customer@example.com",
  phone: "+1234567890"
});
```

## Verify it is working

After installing the tracker:

1. Visit your website in a browser (not incognito -- some ad blockers are more aggressive there)
2. Open your Atribu dashboard
3. Check the **Online Now** counter in the top right -- you should see at least 1 visitor
4. After a few minutes, page views and sessions will appear in the dashboard

<Callout type="warn" title="Ad blockers">
Some browser ad blockers may block the tracking script. If you or your team use ad blockers, you may not see your own visits. To ensure the tracker works for all visitors -- including those with ad blockers -- set up a **custom tracking domain**. This routes tracking requests through your own domain instead of `atribu.app`, which ad blockers do not block.

Go to **Settings > Tracking > Custom Domain** to set this up.
</Callout>

## With the API / an AI agent

Everything above is available without a browser. Three calls:

```bash title="1. A tracking key (idempotent)"
curl -sX POST https://api.atribu.app/api/v1/tracking/keys \
  -H "Authorization: Bearer atb_live_YOUR_KEY"
```

A profile that already has an active key gets **that key back** rather than a
second one, so an agent can call this unconditionally on every run.

```bash title="2. The installer payload"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/tracking/snippet"

# or, for a tag manager / a Shopify store
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/tracking/installers/gtm"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/tracking/installers/shopify-pixel"
```

An agent that can edit the site puts the snippet in. One that cannot hands the
payload to the person who can — that is the hand-off, in its simplest form.

```bash title="3. Confirm an event arrived"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/profile/readiness"
```

Readiness's `tracker_installed` step is `done` once at least one tracking event
reached Atribu in the **last 7 days** — which is the only definition of
"installed" that survives someone deleting the tag next month.

<Callout type="info" title="No browser at all?">
A system that already knows its own outcomes does not need the tracker for
them: `POST /api/v1/events` accepts a server-side outcome event directly. See
[Tracking & server-side events](/docs/api/tracking). You still want the tracker
if you want to know which *ad* produced them.
</Callout>

<Callout type="info" title="Over MCP">
`issue_tracking_key` and `get_tracker_installer` are these calls as tools
(#1058). The key tool is idempotent for the same reason the route is: a profile
with an active key gets that key back.
</Callout>

## Next steps

<Cards>
  <Card title="Step 6 — Connect ad platforms" href="/docs/getting-started/connect-ads">
    Link Meta and Google Ads to see which campaigns drive traffic
  </Card>
  <Card title="Tracking API" href="/docs/api/tracking">
    Keys, installers, and server-side outcome events
  </Card>
  <Card title="How Tracking Works" href="/docs/tracking/how-tracking-works">
    Deep dive into visitors, sessions, cookies, and how the tracker captures data
  </Card>
  <Card title="Identity Resolution" href="/docs/concepts/identity-resolution">
    How anonymous visitors become known customers across devices and channels
  </Card>
</Cards>

---

# Step 6 — Connect Meta and Google

This page is the **In the app** track for step 6 of the
[golden path](/docs/getting-started/overview). The agent track is at the
bottom, under *With the API / an AI agent*.

Connecting your ad platforms lets Atribu pull in your campaigns, ad sets (or ad groups), individual ads, and daily spend data. This is what makes it possible to calculate **ROAS** (Return on Ad Spend) -- the ratio of revenue earned to money spent on advertising. Without this connection, Atribu can still track visitors and conversions, but it cannot tell you which specific campaigns are profitable.

## What gets synced

Once connected, Atribu automatically imports:

| Data | Description |
|------|-------------|
| **Campaigns** | All campaigns in your ad account, including name, status, and objective |
| **Ad sets / Ad groups** | The targeting groups within each campaign |
| **Ads** | Individual ad creatives, including thumbnails and video references |
| **Daily spend** | How much you spent each day, broken down by campaign, ad set, and ad |
| **Impressions** | How many times your ads were shown |
| **Clicks** | How many times people clicked your ads |

Performance metrics like CPM (cost per thousand impressions), CPC (cost per click), and CTR (click-through rate) are calculated automatically from this data.

## Connect your platforms

<Tabs items={["Meta (Facebook & Instagram)", "Google Ads"]}>
<Tab value="Meta (Facebook & Instagram)">

<Steps>
<Step>

### Open Integrations

In your Atribu dashboard, go to **Settings > Integrations**.

</Step>
<Step>

### Click Connect Meta

Find the Meta card and click **Connect**. You will be redirected to Facebook.

</Step>
<Step>

### Authorize in Facebook

Log in to Facebook (if not already) and review the permissions Atribu is requesting:

- **ads_read** -- Read your ad account data (campaigns, spend, performance)

Click **Continue** to authorize.

</Step>
<Step>

### Select ad accounts

After authorization, choose which ad accounts you want to sync. You can select multiple accounts if you manage ads for several businesses.

</Step>
<Step>

### Done

Atribu begins syncing your data immediately. You will see campaigns and spend data in your dashboard within a few minutes.

</Step>
</Steps>

<Callout type="info" title="Sync frequency">
Atribu syncs your Meta ad data automatically. After the initial import (which pulls historical data), updates arrive throughout the day. Spend data for the current day may take a few hours to reflect the latest numbers, as Meta's reporting API has its own delays.
</Callout>

<Callout type="warn" title="Re-authorization">
If Atribu adds new features that require additional permissions, you may need to reconnect your Meta account. When this happens, you will see a banner in Settings. Facebook only shows the permissions dialog when Atribu explicitly requests it, so the reconnection process is quick -- your existing data is preserved.
</Callout>

</Tab>
<Tab value="Google Ads">

<Steps>
<Step>

### Open Integrations

In your Atribu dashboard, go to **Settings > Integrations**.

</Step>
<Step>

### Click Connect Google Ads

Find the Google Ads card and click **Connect**. You will be redirected to Google.

</Step>
<Step>

### Sign in with Google

Sign in with the Google account that has access to your Google Ads account. Review the permissions and click **Allow**.

</Step>
<Step>

### Select your customer account

If your Google account has access to multiple Google Ads customer accounts (common for agencies using a Manager Account), select the one you want to sync.

</Step>
<Step>

### Done

Atribu begins syncing your Google Ads data. Campaigns, ad groups, ads, and spend data will appear in your dashboard within minutes.

</Step>
</Steps>

<Callout type="info" title="Manager accounts">
If you use a Google Ads Manager Account (MCC), you can connect individual client accounts underneath it. Each client account syncs independently.
</Callout>

</Tab>
</Tabs>

## Troubleshooting

### I do not see my ad accounts

- **Meta**: Make sure you are logging in with a Facebook account that has at least "Advertiser" access to the ad account. If you manage ads through a Business Manager, the ad account must be shared with your personal Facebook account.
- **Google Ads**: Make sure you are signing in with the Google account that has direct access (or Manager Account access) to the Google Ads customer account.

### Data is missing or incomplete

- **Recent changes**: After connecting, the initial sync pulls historical data. This can take a few minutes for accounts with many campaigns. Check back in 10-15 minutes.
- **Paused campaigns**: Atribu imports all campaigns regardless of status (active, paused, archived). If you do not see a specific campaign, check that you connected the correct ad account.
- **Spend shows zero**: Some campaigns (especially brand awareness campaigns) may have impressions but very low or zero recorded spend on certain days. This is normal.

### I need to reconnect

If your connection stops working (for example, if you changed your Facebook password or revoked access), go to **Settings > Integrations**, find the platform, and click **Reconnect**. Your historical data is preserved.

## With the API / an AI agent

A connect is an **OAuth consent**: it needs a browser and a person who can
grant it. What an agent can do is start it, hand the URL over, and detect
completion.

```bash title="Poll until the connection appears and is healthy"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/connections"
```

Only `connected` and `syncing` are working. Anything else is a connection that
exists and is not delivering — readiness reports it as `degraded`, never
`missing`, because the fix is to **re**-connect.

**When one consent exposes several ad accounts**, the connect lands *pending*
and the choice is explicit:

```bash
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/connections/pending/meta_ads"

curl -sX POST https://api.atribu.app/api/v1/connections/pending/meta_ads/finalize \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"account_id": "act_1234567890"}'
```

**Mint a session-less URL instead of sending them to the console** (#1046):

```bash title="Hand a Meta connect to a human"
curl -sX POST https://api.atribu.app/api/v1/connections/meta_ads/handoff \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" -d '{}'
```

`meta_ads` · `google_ads` · `google_search_console` · `gohighlevel` · `stripe` ·
`mercadopago`. The response **is** a [hand-off](/docs/api/handoffs) — the same
shape `GET /api/v1/handoffs/{id}` returns, so mint and poll need one parser.
Whoever holds `url` completes it: signed out, on a phone, with no Atribu
account. Poll until `status` settles.

A completed connect answers one of two `result` shapes: `connection_id` when the
consent resolved to exactly one account, or `pending_selection` when a human
must choose — which is what the two `pending` routes above are for. A failure
names its `reason` (`provider_denied`, `no_candidates`, `connect_failed`,
`token_exchange_failed`, `origin_not_allowed`, `handoff_unusable`).

<Callout type="warn" title="Shopify cannot have one, and that is not an omission">
A Shopify install begins **inside Shopify** — the merchant opens the App Store
listing and Shopify calls Atribu with an HMAC-signed request. There is no
consent Atribu can start on the merchant's behalf, so a hand-off URL would have
nowhere to go. Send them to the listing. See
[Connections](/docs/api/connections).
</Callout>

<Callout type="info" title="Over MCP">
`start_connect` mints the same hand-off and `get_handoff` polls it — both thin
wrappers on the two routes above, so the MCP and REST journeys are the same
journey (#1058).
</Callout>

<Callout type="info" title="Stop polling — subscribe (#1049)">
Subscribe to `connection.connected` and `connection.reconnect_required` with
`providers: ["atribu"]` and stop polling entirely. See
[Webhooks](/docs/api/webhooks).
</Callout>

## Next steps

<Cards>
  <Card title="Step 7 — Connect your CRM" href="/docs/getting-started/connect-crm">
    Sync contacts, deals, and appointments from your CRM
  </Card>
  <Card title="Connections API" href="/docs/api/connections">
    Reading, finalizing and revoking a connection
  </Card>
  <Card title="Meta Integration Details" href="/docs/integrations/meta">
    Video previews, CAPI conversion export, and Meta-specific configuration
  </Card>
  <Card title="Google Ads Integration Details" href="/docs/integrations/google-ads">
    Conversion export, Manager Account support, and Google Ads-specific setup
  </Card>
</Cards>

---

# Step 7 — Connect your CRM

This page is the **In the app** track for step 7 of the
[golden path](/docs/getting-started/overview). The agent track is at the
bottom, under *With the API / an AI agent*.

If you use GoHighLevel (GHL) as your CRM, connecting it to Atribu gives you visibility into the full customer journey -- from the first ad click to a booked appointment to a closed deal. Atribu syncs your contacts, opportunities (deals), and calendar events, and maps your pipeline stages to conversion types so you can track progress at every step.

## What gets synced

| Data | Description |
|------|-------------|
| **Contacts** | Name, email, phone number. Used to build the identity graph -- matching CRM contacts to website visitors. |
| **Opportunities** | Deals in your pipeline, including stage, value, and UTM attribution data. GHL stores which ad campaign originated each deal. |
| **Calendar events** | Booked appointments, including the calendar name, assigned team member, and linked contact. |
| **Pipeline stages** | Your pipeline configuration, mapped to Atribu conversion types (see below). |

## Connect GoHighLevel

<Steps>
<Step>

### Open Integrations

In your Atribu dashboard, go to **Settings > Integrations**.

</Step>
<Step>

### Click Connect GoHighLevel

Find the GoHighLevel card and click **Connect**. You will be redirected to the GHL Marketplace.

</Step>
<Step>

### Select your location

GHL uses "locations" (also called sub-accounts) to organize different businesses. Select the location you want to connect to this Atribu profile.

</Step>
<Step>

### Authorize permissions

Review and approve the permissions Atribu is requesting:

- **Contacts** (read-only) -- Access to your contact records
- **Opportunities** (read-only) -- Access to your deals and pipeline data
- **Locations** (read-only) -- Access to your location details
- **Calendars** (read-only) -- Access to your calendars and booked events

Click **Authorize** to complete the connection.

</Step>
<Step>

### Sync begins automatically

Atribu starts importing your GHL data right away. Contacts, opportunities, and calendar events will appear in your dashboard within a few minutes.

</Step>
</Steps>

## Pipeline stage mapping

When GHL opportunities move through your pipeline stages, Atribu maps each stage to a **conversion type**. This is how Atribu knows whether a deal represents a new lead, a booked appointment, or a closed deal.

<Callout type="info" title="Conversion types explained">
Atribu uses these standard conversion types:

- **lead_created** -- A new lead entered your pipeline (for example, someone filled out a form or was added as a contact)
- **appointment_booked** -- A meeting or consultation was scheduled
- **qualified** -- The lead has been vetted and meets your criteria
- **closed_won** -- The deal was won and the client committed
- **payment_received** -- Actual payment was collected (this comes from Stripe/MercadoPago, not GHL)

Your GHL pipeline stages are matched to these types automatically based on stage names. You can customize the mapping in **Settings > Conversions**.
</Callout>

## Understanding pipeline value vs. cash revenue

<Callout type="warn" title="Pipeline values are not real revenue">
GHL deal values represent the **projected worth** of a deal -- what you expect to earn if the deal closes. These are useful for forecasting, but they are not the same as money in your bank account.

Atribu separates these clearly:

- **Pipeline value** (from GHL) -- Shown in the "Pipeline" section of your dashboard. Useful for tracking deal flow and sales performance, but never included in ROAS.
- **Cash revenue** (from Stripe/MercadoPago) -- Shown in the "Revenue" section and used for all ROAS calculations. This is actual collected payment.

A $10,000 deal in your GHL pipeline only counts toward ROAS once the client actually pays $10,000 through Stripe or MercadoPago.
</Callout>

## Calendar integration

Once GHL is connected, the **Calendar** page in Atribu shows your booked appointments alongside attribution data. For each appointment, you can see:

- Which ad campaign brought the customer in
- The customer's full journey (pages viewed, forms submitted)
- Deal value and pipeline stage

Atribu merges live GHL calendar data with its own attribution records, so you get real-time appointment status combined with marketing attribution in one view.

## GHL and Meta native lead forms

Many leads that come through Meta (Facebook and Instagram) native lead forms go directly to GHL without the person ever visiting your website. These leads have campaign data from Meta's attribution (which ad they clicked, which ad set, etc.) but no website visit to track.

Atribu handles this automatically by creating **synthetic touchpoints** -- attribution records based on the campaign data that GHL passes along. This means even leads from Instagram lead forms that never visited your site are attributed to the correct ad campaign.

## Troubleshooting

### Missing contacts

GHL's contacts API may not return every contact in a single sync. Contacts that were created through opportunities (deals) rather than direct form submissions may take an additional sync cycle to appear. If specific contacts are missing after 24 hours, try triggering a manual re-sync from **Settings > Integrations**.

### Pipeline stages are not mapping correctly

Go to **Settings > Conversions** to review and adjust your stage-to-conversion mappings. Atribu matches stages by keyword (for example, a stage named "Appointment Set" maps to `appointment_booked`). If your stage names use non-standard labels, you may need to map them manually.

### Duplicate contacts

If the same person appears under multiple contact records in GHL (for example, with different email addresses), Atribu's identity resolution system will try to merge them based on shared identifiers (email, phone, contact ID). In some cases, manual deduplication in GHL may be needed.

## With the API / an AI agent

This step's readiness key is `crm_or_outcome_source_connected`, and it has
**two** ways to be satisfied — which is the thing worth knowing here.

**Either** a healthy `gohighlevel` or `shopify` connection:

```bash
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/connections"
```

Starting one is an OAuth consent, so it takes the same hand-off as
[step 6](/docs/getting-started/connect-ads):

```bash
curl -sX POST https://api.atribu.app/api/v1/connections/gohighlevel/handoff \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" -d '{}'
```

A GoHighLevel agency token usually exposes several **locations**, so expect
`result.pending_selection` and finish with
`GET /api/v1/connections/pending/gohighlevel` +
`POST …/finalize`. A Shopify hand-off also needs the store in `shop`. See
[Connections](/docs/api/connections).

**Or** outcome events posted directly by your own system:

```bash title="A closed sale, from an ERP, with no browser anywhere"
curl -sX POST https://api.atribu.app/api/v1/events \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "sale_closed",
    "idempotency_key": "erp-invoice-88213",
    "occurred_at": "2026-09-05T14:02:00Z",
    "value": 249000,
    "currency": "CLP",
    "email": "customer@example.com"
  }'
```

<Callout type="warn" title="The idempotency key is required whenever the event carries money">
Without one it defaults to a fresh UUID, and a retried sale becomes a **second
conversion**. `event_type` is free text: what gives it meaning is a
[conversion definition](/docs/getting-started/define-conversions) that claims
it, in step 9.
</Callout>

A connection that exists and is **broken** outranks the API-events fallback in
readiness's `why`: an ERP that posted an event last month does not make a dead
GoHighLevel link fine.

### Map the pipeline stages

`GET /api/v1/connections/gohighlevel/stages` returns everything a stage-mapping
screen needs in one read: every stage of every pipeline, the outcome Atribu
suggests for it (with a label ready to show, in `locale=es` or `en`), the
mapping in force, and `no_suggestion_reason` when the stage name does not point
to an outcome. It reads Atribu's own copy of the pipelines and calls no model,
so the same stage name always gets the same suggestion.

```bash
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/connections/gohighlevel/stages?locale=es"
```

Write a stage's mapping with its `outcome_definition_id`. The write is
idempotent, so applying every suggestion is safe to retry:

```bash
curl -sX PATCH https://api.atribu.app/api/v1/outcome-definitions/OUTCOME_DEFINITION_ID \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" -d '{"event_key": "closed_won"}'
```

Reading needs `connections:read` or `analytics:read`; writing needs
`goals:write`. `can_write` and each stage's `writable` say up front whether a
write would be refused (`write_blocked_reason` says why: `insufficient_scope` or
`entitlement_required`). `status: "awaiting_sync"` means the pipelines have not
been read yet; `next_step` then holds the call that fixes it, with the same key:

```bash
curl -sX POST https://api.atribu.app/api/v1/connections/sync \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"provider": "gohighlevel", "resource": "pipelines"}'
```

Poll `GET /api/v1/connections/sync/status?provider=gohighlevel`, then read the
stages again. `status: "reconnect_required"` keeps the stages listed and
editable; offer the reconnect hand-off. Before auto-applying, skip stages whose
`current.source` is `user` and treat `engine_default` as unset.

A new stage whose name matches no known outcome is stored as `unmapped`: it
counts as **no** outcome (no conversion, no attribution) until you map it. A deal
GoHighLevel marks won or lost still counts, whatever its stage.

## Next steps

<Cards>
  <Card title="Step 8 — Connect payments" href="/docs/getting-started/connect-payments">
    Link Stripe or MercadoPago so cash can reach a conversion
  </Card>
  <Card title="Revenue Types" href="/docs/concepts/revenue-types">
    Understand the difference between cash, pipeline, and gross revenue
  </Card>
  <Card title="Attribution Models" href="/docs/concepts/attribution-models">
    Learn how Atribu assigns credit across touchpoints
  </Card>
  <Card title="GoHighLevel Integration Details" href="/docs/integrations/gohighlevel">
    Pipeline mapping, calendar merge, and GHL-specific configuration
  </Card>
  <Card title="Synthetic Touches" href="/docs/concepts/synthetic-touches">
    How off-site leads from Meta lead forms get attributed
  </Card>
</Cards>

---

# Step 8 — Connect payments

This page is the **In the app** track for step 8 of the
[golden path](/docs/getting-started/overview). The agent track is at the
bottom, under *With the API / an AI agent*.

Connecting your payment provider is what unlocks the most valuable metric in Atribu: **real revenue attribution**. Once connected, every payment is matched back to the ad campaign, ad set, and specific ad that originally brought the customer to your site. This lets you calculate **ROAS** (Return on Ad Spend) -- the amount of revenue you earn for every dollar spent on ads.

## What gets tracked

When a payment comes through Stripe or MercadoPago, Atribu creates an **outcome event** -- a record that something valuable happened. The attribution engine then works backward through the customer's journey to find which ad touchpoints led to that payment.

For each payment, Atribu captures:

| Data | Description |
|------|-------------|
| **Amount** | The payment amount in its original currency |
| **Currency** | The currency of the transaction (automatically converted for reporting) |
| **Customer email** | Used to match the payment to a known visitor via the identity graph |
| **Billing details** | Name, phone, and address when available |
| **Timestamp** | When the payment occurred |

## Connect your provider

<Tabs items={["Stripe", "MercadoPago"]}>
<Tab value="Stripe">

<Steps>
<Step>

### Open Integrations

In your Atribu dashboard, go to **Settings > Integrations**.

</Step>
<Step>

### Click Connect Stripe

Find the Stripe card and click **Connect**. You will be redirected to the Stripe authorization page.

</Step>
<Step>

### Authorize in Stripe

Review the permissions and click **Connect** or **Authorize**. If you manage multiple Stripe accounts, select the one you want to connect.

</Step>
<Step>

### Done

That is it. Stripe webhooks are configured automatically -- you do not need to set up any webhook endpoints manually. Payments will start appearing in Atribu as soon as they are processed.

</Step>
</Steps>

<Callout type="info" title="Automatic webhook setup">
When you connect Stripe, Atribu automatically registers webhook listeners for payment events. It listens for `charge.succeeded`, `payment_intent.succeeded`, and `checkout.session.completed` events. You do not need to configure anything in the Stripe dashboard.
</Callout>

### How Stripe attribution works

Stripe payments typically do not contain UTM parameters or ad campaign data. Instead, Atribu uses the **identity graph** to connect payments to ad clicks:

1. A visitor clicks your ad and lands on your site. The tracker records their anonymous ID and the campaign details from the URL.
2. The visitor fills out a form (or completes a booking). The tracker automatically calls `identify()` with their email, linking their anonymous browser ID to a known customer.
3. Later, when they pay via Stripe, the payment arrives with the same email. Atribu matches it to the same customer profile.
4. The attribution engine traces back through the customer's touchpoints to find which ad brought them in.

This is why installing the tracker and having forms on your site is so important -- it creates the bridge between anonymous ad clicks and real payments.

</Tab>
<Tab value="MercadoPago">

<Steps>
<Step>

### Open Integrations

In your Atribu dashboard, go to **Settings > Integrations**.

</Step>
<Step>

### Click Connect MercadoPago

Find the MercadoPago card and click **Connect**. You will be redirected to MercadoPago's authorization page.

</Step>
<Step>

### Authorize access

Log in to your MercadoPago account and approve the connection.

</Step>
<Step>

### Done

Payments will start flowing into Atribu automatically. Like Stripe, the webhook setup is handled for you.

</Step>
</Steps>

</Tab>
</Tabs>

## Revenue types

<Callout type="warn" title="Cash vs. pipeline revenue">
Atribu distinguishes between different types of revenue. Only **cash revenue** (actual payments from Stripe or MercadoPago) counts toward your ROAS calculation. Here is why this matters:

- **Cash revenue** -- Real money received through Stripe or MercadoPago. This is the only number used for ROAS.
- **Pipeline value** -- Deal values from your CRM (GoHighLevel). These are projections of how much a deal might be worth, not confirmed payments. A "$5,000 deal" in your pipeline does not mean $5,000 has been collected.

If pipeline values were mixed into ROAS, a single large deal estimate could make an underperforming campaign look profitable. Atribu keeps these separate so your ROAS always reflects real, collected cash.
</Callout>

## Troubleshooting

### Payments are not appearing

- **Check the connection status**: Go to **Settings > Integrations** and verify that your Stripe or MercadoPago connection shows as "Connected".
- **Wait a moment**: Webhook delivery is near-instant, but there can be a short delay (under a minute) before the payment appears in your dashboard.
- **Test mode vs live mode**: Make sure you connected your live Stripe account, not a test mode account. Test payments will not appear.

### Revenue shows zero but payments are coming in

- **Check conversion definitions**: Go to **Settings > Conversions** and make sure `payment_received` is configured. This is set up by default, but may have been modified.
- **Identity matching**: If the customer email on the payment does not match any known visitor, the payment will be recorded but may not be attributed to a campaign. Make sure the tracker is installed and forms are being captured.

## With the API / an AI agent

Stripe and MercadoPago are OAuth consents, so the shape is
[step 6](/docs/getting-started/connect-ads)'s — mint a session-less
[hand-off](/docs/api/handoffs), give your user the URL, poll:

```bash
curl -sX POST https://api.atribu.app/api/v1/connections/stripe/handoff \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" -d '{}'
```

<Callout type="info" title="Stripe and MercadoPago honour profile_id and return_url (#1047)">
Stripe and MercadoPago used to ignore `profile_id` and `return_url` on their
`oauth/start`. Both are now on the same `prepareConnectStart` path as the other
five providers, so a consumer bounce returns the merchant where it should.
</Callout>

Webpay (Transbank) has **no OAuth connection to authorize** — the payment is
reported directly:

```bash
curl -sX POST https://api.atribu.app/api/v1/payments/webpay \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"buy_order":"…","amount":249000,"currency":"CLP","email":"customer@example.com"}'
```

<Callout type="warn" title="Why this step is not optional for ROAS">
Only `cash` revenue counts toward ROAS. Without a payment source, every
conversion this profile records is pipeline value — real information, but no
ratio you can spend against. Readiness's `payments_connected` step exists for
exactly this reason.
</Callout>

## Next steps

<Cards>
  <Card title="Step 9 — Define conversions" href="/docs/getting-started/define-conversions">
    Decide what counts, and what its value means
  </Card>
  <Card title="Attribution Models" href="/docs/concepts/attribution-models">
    Learn how credit is assigned across touchpoints
  </Card>
  <Card title="Revenue Types" href="/docs/concepts/revenue-types">
    Understand why only cash payments count for ROAS
  </Card>
  <Card title="Stripe Integration Details" href="/docs/integrations/stripe">
    Webhook setup and Stripe-specific attribution details
  </Card>
</Cards>

---

# Step 9 — Define conversions

By now Atribu is receiving outcome events: leads and appointments from your
CRM, payments from Stripe or MercadoPago, whatever your own systems post. It is
**storing** all of them and **counting** only the ones a conversion definition
claims.

<Callout type="warn" title="One definition is required before anything works">
At least one definition that is both `revenue_type: "cash"` **and**
`attribution_eligible` — otherwise nothing this profile records can ever
produce a ROAS. That is readiness's `conversion_definitions_valid` step, and
until it is `done` the dashboard shows conversions with no revenue behind them.
</Callout>

## The three fields

| field | what it decides |
|---|---|
| source events | which raw `event_type` values this definition claims — unique across the profile |
| revenue type | `cash` \| `pipeline` \| `gross`. **Only `cash` counts toward ROAS.** |
| attribution eligible | whether this conversion may be credited to a touch at all |

`pipeline` is a CRM's *projected* deal value; `gross` is an order before
refunds. Both are tracked and neither is revenue. Mixing them into one number
is the single fastest way to make every ratio on the dashboard meaningless —
see [Revenue types](/docs/concepts/revenue-types).

<Tabs items={["In the app", "With the API / an AI agent"]}>
<Tab value="In the app">

<Steps>
<Step>

### Go to Settings → Outcomes

Every new profile is seeded with sensible defaults — Payment Received (cash),
Lead Created, Appointment Booked, Closed Won, Order Placed. Check that the one
representing *money arriving* is `cash`.

</Step>
<Step>

### Look at what is arriving and unclaimed

The suggestions list shows every event type Atribu has seen that no definition
names, with how often it fires and what it is worth. That is where a missing
`checkout_completed` shows up.

</Step>
<Step>

### Add or edit a definition

Name it, pick its source events, set the revenue type and whether it is
attribution-eligible. Saving replays the profile, so past periods update too.

</Step>
<Step>

### Set the attribution windows

**Settings → Attribution.** Click 30 days, view-through 24 hours, first-touch
90 days are the defaults, and defaults are a legitimate answer. Set
`cash_window_days` only if your sales close long after the click.

</Step>
</Steps>

Full walkthrough: [Conversion goals](/docs/settings/goals) and
[Attribution windows](/docs/settings/attribution-windows).

</Tab>
<Tab value="With the API / an AI agent">

**Suggestions first — never invent an event name.**

```bash title="What is arriving that nothing claims"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/goals/definitions/suggestions"
```

Each row carries `suggested_revenue_type`, `suggested_display_name`,
`suggested_attribution_eligible` and `suggested_meta_event_name` — pre-filled
form fields, **not** classifications. Show them to your human, or say plainly
that you accepted them.

```bash title="Preview before you write"
curl -sX POST https://api.atribu.app/api/v1/goals/definitions/preview \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"source_event_names":["payment_received"],"revenue_type":"cash"}'
```

```bash title="Create it"
curl -sX POST https://api.atribu.app/api/v1/goals/definitions \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "conversion_key": "payment_received",
    "display_name": "Payment received",
    "source_event_names": ["payment_received"],
    "revenue_type": "cash",
    "attribution_eligible": true
  }'
```

**Scope:** `goals:write`. A `409` names the definition that already claims one
of your source events — two definitions over one event would double-count it in
every report.

```bash title="Set the windows"
curl -sX PATCH https://api.atribu.app/api/v1/profile/attribution-settings \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"click_window_days": 30}'
```

<Callout type="warn" title="Both writes queue a full-profile replay">
A definition change re-derives conversions from stored outcome events; a window
change redefines which touches attach to which conversion. **Past periods
change.** That is why `preview` and `diff` exist — call them first.
</Callout>

<Callout type="info" title="Over MCP">
`list_outcome_events`, `suggest_conversion_definitions`,
`create_conversion_definition` and `set_attribution_windows` are the same four
calls as tools (#1058). The two writes take `mode: "preview"` — which costs
nothing and returns what would be captured — before `mode: "confirm"`.
</Callout>

</Tab>
</Tabs>

## Next steps

<Cards>
  <Card title="Step 10 — Verify" href="/docs/getting-started/verify">
    Confirm a conversion was actually credited to a touch
  </Card>
  <Card title="Conversion definitions API" href="/docs/api/goals">
    Suggestions, preview, diff, and the legacy route
  </Card>
  <Card title="Revenue types" href="/docs/concepts/revenue-types">
    Cash, pipeline, gross — and which one is revenue
  </Card>
</Cards>

---

# Step 10 — Verify

Everything up to here was configuration. This step asks whether it **worked**.

<Callout type="warn" title="Pressing Finish in the wizard is not verification">
`onboarding_completed` means one thing: a person pressed **Finish**. Every step
of the wizard is skippable, so it is not evidence that any step is done. On
prod, 12 of 32 profiles were "onboarded" while 8 had an ad platform and 4 a
Meta destination.
</Callout>

<Tabs items={["In the app", "With the API / an AI agent"]}>
<Tab value="In the app">

The **setup-status strip** at the top of the dashboard is this checklist,
rendered: what is done, what is missing, and the one next thing to do. It does
not disappear after your first connection — it stays until the profile is
actually finished.

Beyond it, three places tell you whether the numbers can be trusted:

- **Data quality** — attribution coverage, UTM health, instrumentation health.
- **Trust signals** — the honesty banners that say what a number *cannot* see.
- **Customer journeys** — open one customer and look at their touchpoints. If a
  known payer shows a journey ending in a real ad click, the whole chain works.

</Tab>
<Tab value="With the API / an AI agent">

```bash title="One call, twelve steps, in order"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/profile/readiness"
```

```json title="What matters in the response"
{
  "data": {
    "summary": { "done": 9, "total": 12, "next_step": "capi_destination_enabled" },
    "steps": [{ "key": "…", "status": "…", "why": "…", "next": {}, "docs_url": "…" }]
  }
}
```

`summary.next_step` is the **first** non-`done` step in golden-path order —
first, not "most important": the order *is* the priority. Every non-`done` step
carries a `next` that is either `{method, path}` (a call you can make right now)
or `{handoff_kind}` (a class of action only a person can take).

Four statuses, and `degraded` is the one clients get wrong: it means
*configured and not working*, so the fix is to **re**-connect. Drawing it as
`missing` tells someone to connect what they already connected.

Over MCP: `get_readiness` (and `whoami` reports the same summary).

```bash title="Do not wait for the scheduled run"
curl -sX POST https://api.atribu.app/api/v1/attribution/recompute \
  -H "Authorization: Bearer atb_live_YOUR_KEY"
```

`GET /api/v1/quality/attribution` shows how many conversions carry a touch at
all, and `GET /api/v1/profile/freshness` says when the profile was last
recomputed — the usual explanation for a dashboard that looks wrong.

</Tab>
</Tabs>

## Nothing is attributed yet — broken, or waiting?

`first_conversion_attributed` is an **outcome** of the earlier steps, not a
setting, so it is the one step you cannot fix directly. Work through this
order:

1. **Is `attribution_enabled` blocked?** A partner-provisioned profile
   deliberately does not hold the entitlement — every other step can be perfect
   and it will still attribute nothing.
2. **Is there a `cash` + attribution-eligible definition?**
   [Step 9](/docs/getting-started/define-conversions).
3. **Did any conversion happen at all?** `GET /api/v1/goals/outcome-counts`.
4. **Do your visitors carry ad clicks?** `GET /api/v1/quality/utm` finds links
   that lost their parameters.
5. **Has a recompute run since?** `GET /api/v1/profile/freshness`.

## Next steps

<Cards>
  <Card title="Step 11 — Send it back to Meta" href="/docs/getting-started/send-to-meta">
    Destination, rule, test event
  </Card>
  <Card title="Readiness API" href="/docs/api/readiness">
    All twelve steps, the four statuses, and what `next` means
  </Card>
  <Card title="Data quality" href="/docs/features/data-quality">
    Coverage, UTM health, instrumentation health
  </Card>
</Cards>

---

# Step 11 — Send it back to Meta

This is what the previous ten steps were for. Atribu knows which conversions
are real and what they were worth; Meta's optimizer does not, unless you tell
it. **Conversion Sync** is that channel.

<Callout type="warn" title="Two objects are required, and both are silent when missing">
A destination with **no rules sends nothing**, and an export against a profile
with neither succeeds and ships zero — with no error anywhere. There is **no
default mapping**. That is why readiness scores them as two separate steps,
`capi_destination_enabled` and `capi_rules_enabled`.
</Callout>

<Tabs items={["In the app", "With the API / an AI agent"]}>
<Tab value="In the app">

<Steps>
<Step>

### Open Conversion Sync

The checklist there walks the same four things this page does.

</Step>
<Step>

### Add the destination

Pick the Meta connection and the **dataset (pixel) id** from Events Manager.
For Google Ads, the conversion action resource name.

</Step>
<Step>

### Create a signal rule

Choose the conversion definition (step 9's `cash` one), the destination, and
the Meta event name — `Purchase` for money. Choose how value is sent: the
conversion's own amount, a fixed value, or none.

</Step>
<Step>

### Send a test event and find it in Meta

Copy the `test_event_code` from **Events Manager → Test Events**, send the
test, and watch it arrive. Then activate the rule.

</Step>
</Steps>

Full walkthrough, including privacy modes and skip reasons:
[Conversion Sync](/docs/features/conversion-sync).

</Tab>
<Tab value="With the API / an AI agent">

Four calls, in this order. Scopes: `exports:write` for the configuration and
the test, `attribution:write` for the export itself. **`exports:write` is not
granted by any OAuth connect scope** — a workspace admin opts a key into it.

```bash title="1. The destination"
curl -sX POST https://api.atribu.app/api/v1/exports/destinations \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "destination": "meta_capi",
    "connection_id": "0d0e6c8a-…",
    "meta_pixel_id": "1234567890",
    "enabled": true
  }'
```

```bash title="2. The rule"
curl -sX POST https://api.atribu.app/api/v1/exports/rules \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Payment → Purchase",
    "source_kind": "conversion_definition",
    "source_ref_id": "6f1c…",
    "destinations": { "meta_capi": { "enabled": true } },
    "platform_event_name_overrides": { "meta_capi": "Purchase" },
    "meta_custom_event_type": "PURCHASE",
    "value_mode": "event_value"
  }'
```

`source_ref_id` is a **`conversion_definitions`** id from
`GET /api/v1/goals/definitions` — not an `outcome_definitions` id, which is a
CRM pipeline-stage label and a different thing entirely.

```bash title="3. The test event"
curl -sX POST https://api.atribu.app/api/v1/exports/test \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"destination_id":"…","test_event_code":"TEST12345","rule_id":"…"}'
```

Search the returned `event_id` in **Events Manager → Test Events**. Passing
`rule_id` makes the test borrow that rule's event name and privacy override, so
it exercises the rule you are actually verifying. Capped at 5 sends per profile
per minute.

```bash title="4. The export"
curl -sX POST https://api.atribu.app/api/v1/exports \
  -H "Authorization: Bearer atb_live_YOUR_KEY"
```

`202` with a batch id. Poll `GET /api/v1/exports/{id}`, and read
`GET /api/v1/exports/ledger` for per-delivery detail. **A non-empty ledger is
the proof the four steps worked.**

<Callout type="info" title="`replay_queued` is not a delivery">
Every mutation here answers with `replay_queued` / `replay_error`. It says a
pass was *asked for* — never that anything shipped. The ledger says that.
</Callout>

<Callout type="info" title="Ask a human first">
`require_approval: true` on `POST /api/v1/exports` mints an `approve`
[hand-off](/docs/api/handoffs) instead of exporting, and answers `202` having
written nothing: your human sees exactly what would be sent, approves in a
signed-out browser, and your agent polls the same result back. The MCP tool
`send_meta_conversions` mints it too — required whenever the caller is not a
workspace owner or admin, and available to any caller that asks for it.
</Callout>

<Callout type="info" title="Over MCP">
`configure_meta_capi` does steps 1 and 2 in one tool and `send_test_event` does
step 3 (#1058). Both take `mode: "preview"` first — worth using here, because a
destination is the one object on this page you cannot truly delete.
</Callout>

</Tab>
</Tabs>

## Why the test event matters more than it looks

The test payload carries the same `atribu_src` marker a production event
carries, so it actually fires the Custom Conversion your rule created. A test
that skipped it would fire the pixel and *not* the conversion — the most
misleading possible result while verifying a setup.

`quality_warning` is non-null when the profile's match-quality policy would
block a real event carrying only what a test carries (an IP and a user agent).
The test still sends: one that could not run under a strict policy would prove
nothing.

## Next steps

<Cards>
  <Card title="Step 12 — DPA and plan" href="/docs/getting-started/dpa-and-plan">
    The signature and the payment
  </Card>
  <Card title="Conversion Sync API" href="/docs/api/conversion-sync">
    Every field, every skip reason
  </Card>
  <Card title="Healthcare mode" href="/docs/healthcare/overview">
    If you send health-adjacent conversions, read this first
  </Card>
</Cards>

---

# Step 12 — DPA and plan

Sending a customer's conversion to Meta is a disclosure to a third party.
Atribu will not do it on your behalf without a **Data Processing Agreement**
you have accepted, and the plan you are on decides how many profiles you can
run.

Neither is a step an agent can complete for you: a signature forged on a
human's behalf is not a signature, and a payment is a payment.

<Tabs items={["In the app", "With the API / an AI agent"]}>
<Tab value="In the app">

<Steps>
<Step>

### Accept the DPA

**Settings → Compliance**, or the prompt inside Conversion Sync. The click-wrap
requires scrolling to the end before Accept becomes active, and records who
accepted, when, from where, and which document version.

If you handle health-adjacent data, you want the **BAA** as well —
[Healthcare onboarding](/docs/healthcare/onboarding) is the sequence for that,
and it changes what may be exported at all.

</Step>
<Step>

### Pick a plan

**Settings → Billing.** Free is `$0` with one active ad account. Growth and
Agency are self-serve Stripe checkouts; Enterprise talks to us first.

</Step>
</Steps>

</Tab>
<Tab value="With the API / an AI agent">

Read both states — this much works today:

```bash title="Compliance"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/conversion-sync/legal"
```

```bash title="Plan"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/workspaces/{workspaceId}/subscription"
```

Readiness reports both as steps with a `handoff_kind` — `sign_dpa` and
`checkout` — which is the API telling you plainly: *this one is a person's*.

Today, hand your user a link to **Settings → Compliance** or **Settings →
Billing** and poll the two reads above until they change.

Both are hand-offs now, so neither is a dead end for an agent:

```bash title="The signature"
curl -sX POST https://api.atribu.app/api/v1/legal/dpa/handoff \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" -d '{}'
```

A session-less URL rendering the same click-wrap, recording the same signer
identity, completing the hand-off when they accept. `/baa` is the combined
DPA + HIPAA BAA. A hand-off for an **already-accepted** DPA answers `completed`
immediately — never a second signature. `POST /api/v1/legal/dpa/accept` is the
signed-in owner/admin path, so acceptance has one write however it is reached.
Full detail: [Legal](/docs/api/legal).

```bash title="The payment"
curl -sX POST https://api.atribu.app/api/v1/workspaces/{workspaceId}/checkout-session \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" -d '{"plan":"growth"}'
```

A `checkout` hand-off wrapping a Stripe Checkout URL; the Stripe webhook
completes it. Minting for a workspace already on that plan answers `completed`
with `no_change`. Full detail: [Billing](/docs/api/billing).

<Callout type="info" title="Over MCP">
`sign_dpa` and `start_plan_upgrade` mint the same two hand-offs (#1058). Poll
either with `get_handoff`.
</Callout>

<Callout type="info" title="A growth or agency workspace gets its checkout at creation">
`POST /api/v1/workspaces` with `plan: "growth"` or `"agency"` returns the
checkout hand-off in the `checkout` field of the create response — so an agent
that knows which plan it wants never has to mint one separately. See
[Workspaces & profiles](/docs/api/workspaces).
</Callout>

<Callout type="warn" title="Do not block on this to keep working">
A profile whose DPA is unsigned still tracks, still attributes and still
reports. What it will not do is export to Meta. A profile at its plan's
`active_profiles` limit keeps working too — what is refused is adding
*another* one.
</Callout>

</Tab>
</Tabs>

## What each one gates

| | unsigned DPA | plan limit reached |
|---|---|---|
| tracking, attribution, dashboards | works | works |
| this profile | works | works |
| exporting to Meta / Google | **blocked** | works |
| creating another profile | works | **blocked** |

## Next steps

<Cards>
  <Card title="Step 13 — What next" href="/docs/getting-started/what-next">
    Staying set up without watching it
  </Card>
  <Card title="Conversion Sync" href="/docs/features/conversion-sync">
    Privacy modes, and what is stripped before it leaves
  </Card>
  <Card title="Healthcare & HIPAA" href="/docs/healthcare/overview">
    The BAA, the export allowlist, and what changes
  </Card>
</Cards>

---

# Step 13 — What next

A finished setup decays. An OAuth token expires, a developer strips the UTMs
off a landing page, a CRM pipeline gets renamed, a Meta dataset is replaced.
None of those announce themselves in a dashboard that keeps rendering numbers —
they announce themselves as numbers quietly getting smaller.

<Tabs items={["In the app", "With the API / an AI agent"]}>
<Tab value="In the app">

Three habits, in order of value:

1. **The setup-status strip.** It stays on the dashboard, driven by the same
   readiness checklist. A connection that flips to *reconnect required* shows up
   there before it shows up as missing revenue.
2. **Data quality, weekly.** Attribution coverage and UTM health are the two
   numbers that move first when instrumentation breaks.
3. **Scheduled reports.** A report that arrives every Monday is also a
   liveness check: the week it looks wrong, something upstream changed.

</Tab>
<Tab value="With the API / an AI agent">

**Stop polling. Subscribe.**

```bash title="Tell me when things change"
curl -sX POST https://api.atribu.app/api/v1/webhooks/subscriptions \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/atribu",
    "providers": ["atribu"],
    "events": [
      "connection.reconnect_required",
      "connection.revoked",
      "recompute.completed",
      "conversion.attributed",
      "export.completed",
      "export.failed",
      "profile.freshness.changed"
    ]
  }'
```

<Callout type="warn" title="The providers list is the part people miss">
Every platform lifecycle event carries the provider `atribu`, not a channel. A
subscription whose `providers` list does not include it receives **none** of
them, whatever its `events` list says. The connector's real provider rides in
`data.provider`.
</Callout>

Then keep a much smaller loop:

- `GET /api/v1/profile/readiness` on a schedule — a `degraded` step is a broken
  connection, and its `why` already says which and what to do.
- `GET /api/v1/profile/freshness` before drawing a number you are about to act
  on.
- Verify the HMAC signature on every delivery before trusting it, and use
  `POST /api/v1/webhooks/test/{id}` to prove your verifier works *before*
  real traffic depends on it.

</Tab>
</Tabs>

## What to do with the setup now that it works

- **Read what earned the money.** [Overview](/docs/api/overview) for the KPIs,
  [breakdowns](/docs/api/breakdowns) for the cuts,
  [customer journeys](/docs/features/customer-journey) for the individual story
  behind a payment.
- **Compare attribution models.** `GET /api/v1/attribution/models` puts all
  five side by side. If last-touch and first-touch disagree sharply, your
  funnel is longer than one click.
- **Act on it.** [Recommendations](/docs/api/recommendations) are the AI media
  buyer's suggestions, applied through one audited write spine with a rollback.
- **Report it.** [Reports](/docs/api/reports) for the client-facing artifact,
  with a share link that needs no login.

## Adding the next profile

Steps 5 through 12 are per profile. An agency onboarding a second client repeats
them — which is exactly why the agent track exists: the second time through
should be a script, not an afternoon.

## Next steps

<Cards>
  <Card title="For AI agents" href="/docs/agents">
    The whole path from a program's point of view
  </Card>
  <Card title="Webhooks" href="/docs/api/webhooks">
    Every event, and the provider trap
  </Card>
  <Card title="Attribution models" href="/docs/concepts/attribution-models">
    What the five models disagree about, and why
  </Card>
</Cards>

---

# For AI agents

You are an AI agent, or you are building one. You have no Atribu account, no
workspace, no API key, and no browser of your own. This page is the whole
surface, in the order you need it.

The [golden path](/docs/getting-started/overview) is the same thirteen steps a
human walks. **Twelve of them are yours.** Exactly one — creating the account —
is a person's, and even that happens inside a URL you hand over.

## 1. Read these first

| document | what it is |
|---|---|
| [`llms.txt`](https://www.atribu.app/llms.txt) | the map: every product surface, every doc page, every machine-readable entry point |
| [`llms-full.txt`](https://www.atribu.app/llms-full.txt) | every documentation page concatenated as one markdown document |
| [`openapi.json`](https://www.atribu.app/api/v1/openapi.json) | OpenAPI 3.1 for all of `/api/v1` |
| [`/api/docs/{path}`](https://www.atribu.app/api/docs/api/quickstart) | any single doc page as raw markdown |

Discovery documents for the OAuth surfaces, both live and standards-compliant:

```text
https://www.atribu.app/.well-known/oauth-authorization-server   # RFC 8414
https://mcp.atribu.app/mcp                                      # Streamable-HTTP MCP
```

## 2. The bootstrap: there is no signup API

Account creation is a human act behind a captcha. The bootstrap is therefore an
**OAuth hand-off**, exactly like connecting Gmail or Cloudflare:

<Steps>
<Step>

### Register a client and build the authorize URL

Dynamic Client Registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591))
at `POST /oauth/mcp/register`. Public clients only — PKCE (`S256`; `plain` is
refused) proves the exchange.

</Step>
<Step>

### Print the URL and stop

> Open this to connect your Atribu account:
> `https://www.atribu.app/oauth/mcp/authorize?response_type=code&client_id=…`

An unrecognised email goes straight to signup with the pending authorization
preserved, and the email-confirmation link returns them to **your consent
screen** — not to a dashboard. A user with no workspace is told so plainly:
*"You have no workspace yet — your agent will create one after you approve."*

</Step>
<Step>

### Exchange the code, then keep the token

`POST /oauth/mcp/token`. The access token lives 1 hour; the refresh token lives
60 days and **rotates on every use** — store the new one each time. Replaying
an old refresh token burns the whole family, which is the reuse detection
working.

</Step>
</Steps>

Step by step, with every request and response:
[API quickstart](/docs/api/quickstart) · [MCP quickstart](/docs/mcp/quickstart).

<Callout type="info" title="One token, two surfaces">
Since #1084 an `atb_user_…` token is a first-class `/api/v1` principal. The
token the consent screen produced works on `https://mcp.atribu.app/mcp` **and**
on `https://api.atribu.app/api/v1/*`. Nothing else has to be minted, and an API
key is only for a machine job that belongs to a profile rather than to a person.
</Callout>

## 3. Call `whoami` before anything else

```bash
curl -sX POST https://mcp.atribu.app/mcp \
  -H "Authorization: Bearer atb_user_…" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"whoami","arguments":{}}}'
```

It costs zero units and answers with the workspaces, profiles, currency, PII
mode and write-back state the caller **actually** has. The REST equivalent is
`GET /api/v1/workspaces`, which needs no `profile_id` — which is exactly why it
is the right first call.

<Callout type="warn" title="A user token must name a profile">
An API key *is* one profile. A user token may reach several, so
`?profile_id=<uuid>` is **required** on profile-scoped routes: its absence is a
`400`, never a guess.
</Callout>

## 4. Bootstrap the objects

```bash title="workspace → profile → tracking key → readiness"
curl -sX POST https://api.atribu.app/api/v1/workspaces \
  -H "Authorization: Bearer atb_user_…" -H "Content-Type: application/json" \
  -d '{"name": "My Agency"}'

curl -sX POST https://api.atribu.app/api/v1/profiles \
  -H "Authorization: Bearer atb_user_…" -H "Content-Type: application/json" \
  -d '{"workspace_id": "WORKSPACE_ID", "name": "My Website"}'

curl -sX POST "https://api.atribu.app/api/v1/tracking/keys?profile_id=PROFILE_ID" \
  -H "Authorization: Bearer atb_user_…"

curl "https://api.atribu.app/api/v1/profile/readiness?profile_id=PROFILE_ID" \
  -H "Authorization: Bearer atb_user_…"
```

Both creates are **idempotent** — the workspace on `(you, name)` for a short
window, the tracking key on "this profile already has an active one". Retrying
after a dropped connection is safe and is not a duplicate.

An unwanted workspace is archived with
[`DELETE /api/v1/workspaces/{workspaceId}`](/docs/api/workspaces) — owner only,
terminal, and idempotent the same way: the second call answers `200` with
`archived: false`.

## 5. Readiness is your program counter

```http
GET /api/v1/profile/readiness
```

Twelve steps in golden-path order, each with a `status`, a `why`, a `next` and a
`docs_url`. `summary.next_step` is the first non-`done` step — **first**, not
"most important": the order *is* the priority.

Every non-`done` step's `next` is one of two shapes, and the difference is the
whole design:

| shape | meaning |
|---|---|
| `{"method": "…", "path": "…"}` | a call you can make **right now** with the credential you hold |
| `{"handoff_kind": "…"}` | a class of action only a **person** can take |

Four hand-off kinds, and each is honest about why: `connect` (an OAuth consent
needs a browser), `sign_dpa` (a signature forged on someone's behalf is not a
signature), `checkout` (a payment), `contact_support` (Atribu itself has to
change something).

Do not use `onboarding_completed` for this. It means one thing — a person
pressed **Finish** in the wizard — and every wizard step is skippable. Full
detail: [Readiness](/docs/api/readiness).

## 6. The hand-off pattern

> *Give the user this URL. Poll this handle. Continue when it settles.*

```http
GET /api/v1/handoffs/{id}
```

1. **Mint** through the route for the thing you need — a hand-off with nothing
   behind it is a URL nobody can complete, so there is deliberately no
   `POST /api/v1/handoffs`.
2. **Hand the URL over.** It is returned only while `status` is `pending`.
   Capture it once and give it to your user; re-poll for `status`, not for the
   URL. Whoever holds it can complete it — no Atribu account, no session, no
   desktop.
3. **Poll.** An expired hand-off is a `200` with `status: "expired"`, never a
   `404`. A `404` means the id names nothing your workspace minted.

45 minutes by default. Always read `expires_at`; a kind whose underlying object
has its own clock mirrors that clock instead. Full detail:
[Hand-offs](/docs/api/handoffs).

<Callout type="info" title="Stop polling where you can">
`connection.connected`, `connection.reconnect_required`, `handoff.completed`,
`recompute.completed`, `conversion.attributed`, `export.completed` and
`export.failed` all fan out as webhooks (#1049). Subscribe with
`providers: ["atribu"]` — every platform lifecycle event carries that provider,
not a channel, and a subscription that omits it receives none of them. See
[Webhooks](/docs/api/webhooks).
</Callout>

## 7. What is live over MCP

The whole golden path is. `tools/list` carries real input schemas,
`readOnlyHint` / `destructiveHint` annotations, resources and prompts (#1062).
Start here:

| tool | use |
|---|---|
| `whoami` | who am I, what can I reach, what is missing |
| `get_readiness` | the twelve-step checklist |
| `list_workspaces`, `list_profiles` | navigation |
| `apply_recommendation`, `send_meta_conversions` | the two Meta write-backs, `preview → dry_run → confirm` |

`whoami` also folds in a readiness rollup — `next_step` plus the `missing_steps`
— so one free call answers "who am I and what is left". When that forwarded read
fails it answers `null` with a `readiness_error` naming the cause, never an
empty `missing_steps` that would read as "all done".

### The onboarding family (#1058)

Sixteen tools that walk the golden path end to end, grouped by the step they
serve:

| step | tools |
|---|---|
| 3 — workspace + profile | `create_workspace` · `create_profile` |
| 5 — tracker | `issue_tracking_key` · `get_tracker_installer` |
| 6–8 — connect | `start_connect` · `get_handoff` |
| 9 — conversions | `list_outcome_events` · `suggest_conversion_definitions` · `create_conversion_definition` · `set_attribution_windows` |
| 11 — send to Meta | `configure_meta_capi` · `send_test_event` |
| 12 — DPA + plan | `sign_dpa` · `start_plan_upgrade` |
| practice | `create_demo_profile` · `delete_demo_profile` |

<Callout type="warn" title="Every write tool takes `mode`, and `preview` is free">
`mode: "preview"` describes what the call would do and writes nothing;
`mode: "confirm"` performs it. Confirms are idempotent — the key is derived
from the arguments and recorded in the workspace's write-back audit — so a
retried confirm replays rather than doubling. This is the same ceremony the two
older write-backs use, not a second one.
</Callout>

**Every one is a thin wrapper on exactly one `/api/v1` route.** They forward
*your* bearer and return what the API says; the client, the error map, the write
gates and the audit write live in one shared spine rather than sixteen copies.
So no tool holds business logic unreachable over REST — which is what makes
"drive it over MCP" and "drive it over REST" the same journey rather than two
implementations that can drift.

<Callout type="info" title="The approval hand-off is live (#1050)">
A `confirm` from a principal that is not a workspace owner/admin — or any caller
passing `require_approval: true` — mints an `approve` hand-off carrying the
exact preview and writes **nothing**; your human approves in a signed-out
browser and you poll the same result payload back. It is what makes a
non-admin token useful instead of a dead end: before it, such a token was
refused outright with no way to ask anyone.
</Callout>

## 8. Practising against data that is not real (#1069)

```bash title="One call, and the workspace has something to read"
curl -sX POST "https://api.atribu.app/api/v1/profiles/demo?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer atb_user_…"
```

Sixty days of a fictional clinic's Meta traffic, and a Meta CAPI destination
that **records** what it would have sent instead of calling Meta. Over MCP:
`create_demo_profile` / `delete_demo_profile`. Full detail:
[Demo profile](/docs/api/demo-profile).

<Callout type="warn" title="The engine runs for real, which is why the counts are a forecast">
The seed writes *inputs* — ad structure, daily spend, identities, touches, and
`outcome_events` through the same CRM-ingest primitive every real provider uses
— then enqueues an ordinary recompute. `conversions` and
`attribution_touchpoints` across all five models come out of the **pipeline**,
not out of the seed, and the recompute is asynchronous. So the counts in the
response say what the recompute *will* produce; poll
`GET /api/v1/overview` for what it did.
</Callout>

**Its own reads show its own money — that is the point — and every read that
spans a workspace's profiles leaves it out**, so a demo profile can never
inflate a pacing aggregate or a leaderboard. `DELETE` is a hard delete, not an
archive (an archived demo would keep answering with its numbers), and deleting
nothing answers `200` with `deleted: false` rather than `404`: a workspace with
no demo profile is the state you asked for, and a retry must not look like a
failure.

## 9. The skill

If your host is Claude Code, `skills/atribu-attribution-skill` packages the
tool vocabulary, the defaults (cash ROAS, PII masked) and the write-back safety
rules. See [the skill](/docs/mcp/skill).

## Where to go next

<Cards>
  <Card title="The golden path" href="/docs/getting-started/overview">
    The thirteen steps, with an agent track on every one
  </Card>
  <Card title="API quickstart" href="/docs/api/quickstart">
    The bootstrap in full — registration, PKCE, tokens
  </Card>
  <Card title="Readiness" href="/docs/api/readiness">
    Your program counter
  </Card>
  <Card title="Hand-offs" href="/docs/api/handoffs">
    The five kinds, the statuses, the expiry rules
  </Card>
  <Card title="Errors" href="/docs/api/errors">
    Every code, its status, and what to do about it
  </Card>
  <Card title="MCP tools" href="/docs/mcp/tools">
    Every tool, its arguments, and its cost
  </Card>
</Cards>

---

# How Tracking Works

When someone visits your website, Atribu's tracker records what they do — which pages they view, where they came from, and how they interact with your site. This data powers your dashboard, attribution reports, and revenue insights.

```mermaid
flowchart LR
    A["Browser"] -->|page view| B["Atribu Tracker\n(JavaScript)"]
    B -->|events| C["/api/tracking/collect"]
    C --> D["Enrichment Pipeline"]
    D --> E["events_enriched"]
    E --> F["Sessions & Views"]
    F --> G["Dashboard"]
```

---

## Two IDs, two concepts

Atribu uses two identifiers to understand your traffic. Both are generated automatically -- no setup required.

### Visitor ID (anonymous_id)

Think of this as a fingerprint for each device. The same person visiting your site from the same browser and device will always be recognized as the same visitor. This ID is stored in the browser for **1 year**, so returning visitors are counted accurately.

- Stored in `localStorage` as `atribu_anon_id`
- Also written as a first-party cookie (`atribu_visitor_id`) so server-side code can read it
- One per device/browser combination -- if someone visits from their phone and their laptop, that counts as two visitors

### Session ID

A session represents a single **visit**. It starts when someone arrives on your site and ends after **30 minutes of inactivity**. If they come back an hour later, that is a new session (a new visit), but the same visitor.

- Stored in `localStorage` as `atribu_session_id`
- Also written as a cookie (`atribu_session_id`) with a 30-minute expiry
- A new session also starts when the marketing source changes (e.g., a visitor clicks a different ad)

<Callout type="info" title="Visitors vs. sessions">
Your dashboard shows both metrics. **Visitors** tells you how many unique people came to your site. **Sessions** tells you how many total visits happened. One visitor can have many sessions.
</Callout>

---

## What gets captured on every page view

Each time a visitor loads a page, Atribu records:

| Data point | Example |
|-----------|---------|
| **Page URL** | `/pricing`, `/blog/how-to-run-ads` |
| **Referrer** | `google.com`, `facebook.com`, direct |
| **UTM parameters** | `utm_source=facebook`, `utm_medium=paid` |
| **Click IDs** | `fbclid`, `gclid`, `msclkid`, `ttclid` |
| **Device type** | Desktop, mobile, tablet |
| **Browser** | Chrome, Safari, Firefox |
| **Country** | Based on IP address |

---

## How sessions work

<Steps>
<Step>

### Visitor arrives

When someone lands on your site, the tracker checks if an active session exists. If the last activity was more than 30 minutes ago (or no session exists), a new session starts.

</Step>
<Step>

### Activity is tracked

Every page view, form submission, and custom event updates the session's "last seen" timestamp. As long as the visitor keeps interacting within 30-minute windows, it all counts as one session.

</Step>
<Step>

### Session ends

After 30 minutes of inactivity (no page views, no clicks, no events), the session expires. If the visitor returns after that, a new session begins.

</Step>
</Steps>

### Configuring the session timeout

The default timeout is 30 minutes, which works well for most websites. If you need a different value, you can configure it in your tracking snippet:

```js title="Custom session timeout (15 minutes)"
window.ATRIBU_SESSION_TIMEOUT_MINUTES = 15;
```

Valid range: 1 to 120 minutes.

### Source-change detection

By default, a new session starts only after inactivity. You can also start a new session when the marketing source changes -- for example, if a visitor clicks a Google ad, then clicks a Facebook ad 10 minutes later:

```js title="Enable source-change session splitting"
window.ATRIBU_SESSION_MODE = "inactivity_or_source_change";
```

---

## Cookies explained

Atribu writes up to three first-party cookies on your domain:

| Cookie | Lifetime | Purpose |
|--------|----------|---------|
| `atribu_visitor_id` | 1 year | Stores the visitor's anonymous ID so server-side code (like checkout flows) can read it and pass it to payment providers |
| `atb_vid` | 400 days | Only with a [same-origin collector](/docs/tracking/same-origin-collector). Set by the server, HttpOnly, so Safari does not delete it after 7 days; holds the same anonymous ID |
| `atribu_session_id` | 30 minutes | Stores the current session ID for the same reason |

These cookies are **first-party** (set on your own domain), **SameSite=Lax**, and contain only random identifiers -- no personal data.

<Callout type="info" title="Privacy by default">
Atribu does not collect any personal information until a visitor voluntarily identifies themselves (by filling out a form, booking an appointment, etc.). Until that point, visitors are completely anonymous -- Atribu only knows that "someone on this device" viewed certain pages. Learn how this anonymous-to-known transition works in [Identity Resolution](/docs/concepts/identity-resolution).
</Callout>

---

## Next steps

<Cards>
  <Card title="Auto-Capture" href="/docs/tracking/auto-capture">
    How Atribu automatically detects form submissions, bookings, and payments
  </Card>
  <Card title="Identify Users" href="/docs/tracking/identify-users">
    Connect anonymous visitors to real people for full attribution
  </Card>
  <Card title="UTM Parameters" href="/docs/tracking/utm-parameters">
    Tag your traffic sources for accurate channel classification
  </Card>
  <Card title="Custom Domain" href="/docs/tracking/custom-domain">
    Bypass ad blockers by routing tracking through your own domain
  </Card>
</Cards>

---

# Auto-Capture

Atribu automatically captures form submissions, booking completions, and payment redirects -- no extra code needed. These auto-captured events are the foundation of attribution, because they connect anonymous visitors to real people.

---

## Form auto-capture

When a visitor submits a form on your website, Atribu detects it and does three things:

<Steps>
<Step>

### Extracts contact information

The tracker scans the form fields and looks for:

- **Email** -- fields with `type="email"`, or a `name`/`autocomplete` attribute containing `email`
- **Phone** -- fields with `type="tel"`, or a `name` attribute containing `phone` or `telephone`
- **First name** -- fields named `first_name`, `firstName`, `given_name`, `fname`, or with `autocomplete="given-name"`
- **Last name** -- fields named `last_name`, `lastName`, `family_name`, `lname`, `surname`, or with `autocomplete="family-name"`
- **Full name** -- fields named `name` or with `autocomplete="name"` (automatically split into first and last)

Hidden fields and invisible fields are ignored.

</Step>
<Step>

### Identifies the visitor

If an email or phone number is found, the tracker automatically calls `identify()`. This is the critical step that links the anonymous visitor (who clicked an ad) to their real identity (the person who filled out the form).

</Step>
<Step>

### Fires a `lead_submitted` event

A tracking event is recorded with the form ID and the page path, so you can see form submissions in your dashboard and use them as conversion goals.

</Step>
</Steps>

<Callout type="info" title="This is why attribution works">
The form auto-capture is where the magic happens. Before the form fill, Atribu only knows "an anonymous visitor clicked a Facebook ad and viewed 3 pages." After the form fill, Atribu knows "Jane at jane@example.com clicked a Facebook ad." When Jane later makes a payment through Stripe, Atribu connects it back to that original ad click.
</Callout>

<Callout type="warn" title="Custom or unusual form fields">
Atribu detects email and phone fields by checking the `type`, `name`, and `autocomplete` attributes on `<input>`, `<select>`, and `<textarea>` elements. If your form uses non-standard field names (like `data-email` or a custom web component), the tracker may not detect them. In that case, call `identify()` manually. See [Identify Users](/docs/tracking/identify-users) for details.
</Callout>

---

## Booking widget capture

Atribu detects when a visitor completes a booking through embedded scheduling widgets. The following platforms are supported:

| Platform | Detection method |
|----------|-----------------|
| **GoHighLevel** | Listens for the `msgsndr-booking-complete` postMessage from the GHL widget |
| **Calendly** | Listens for the `calendly.event_scheduled` postMessage |
| **Cal.com** | Listens for the `bookingSuccessful` postMessage |
| **Hash-based** | Detects URL hash changes like `#booking`, `#booked`, `#scheduled`, `#confirmed`, or `#thank-you` |

When a booking is detected:

1. Contact information is extracted when available (email, name, phone from the widget's message data)
2. `identify()` is called automatically if contact info is present
3. An `appointment_booked` event is fired

<Callout type="info" title="One booking per session">
To avoid duplicates, the booking event fires only once per session. If a visitor somehow triggers the booking completion message multiple times, only the first one is recorded.
</Callout>

### Custom booking patterns

If you use a booking widget that is not natively supported, you can register custom detection patterns:

```js title="Custom booking pattern"
window.ATRIBU_BOOKING_PATTERNS = [
  { match: "booking-confirmed", widget: "my_scheduler" }
];
```

The tracker will look for the `match` string in any `postMessage` data and fire the `appointment_booked` event when found.

---

## Stripe checkout capture

When you use Stripe Payment Links, customers are redirected back to your website after completing payment. The redirect URL contains a `session_id` parameter like:

```text title="Stripe redirect URL"
https://yoursite.com/thank-you?session_id=cs_live_a1b2c3...
```

Atribu detects this parameter and:

1. Fires a `stripe_checkout_completed` event with the Stripe checkout session ID
2. Cleans the `session_id` parameter from the URL (using `history.replaceState`) so the event does not fire again if the visitor refreshes the page

This allows Atribu to connect the payment to the browser session, which links it back to the original ad click.

<Callout type="info" title="Supported URL parameters">
The tracker checks for `session_id`, `checkout_session`, and `checkout_session_id` in the URL. Only values starting with `cs_` (Stripe's checkout session prefix) are processed.
</Callout>

---

## Scheduler redirect capture (opt-in)

Off by default. When you turn on **Identify visitors from scheduler redirects** in your tracking settings, the tracker reads the email, phone and name that Calendly, GoHighLevel or Acuity add to your thank-you page link, sends them to Atribu in the request body (never in a URL), and removes them from the address bar straight away. With the setting off, nothing is read.

For every profile, whether or not it opted in, Atribu removes those parameters (`invitee_email`, `email`, `phone`, `answer_1`, …) from page URLs and referrers before storing them. Setup for each scheduler: [Scheduler Redirects](/docs/tracking/scheduler-redirects).

---

## Next steps

<Cards>
  <Card title="Identify Users" href="/docs/tracking/identify-users">
    Manual identification for custom forms and login flows
  </Card>
  <Card title="Custom Events" href="/docs/tracking/custom-events">
    Track any action as a custom event
  </Card>
  <Card title="Identity Resolution" href="/docs/concepts/identity-resolution">
    How anonymous visitors become known customers across channels
  </Card>
</Cards>

---

# Identify Users

The `identify()` function is the most important part of Atribu's tracking. It connects an anonymous website visitor to a real person -- and that connection is what makes attribution possible.

---

## Why identification matters

Without identification, Atribu sees two disconnected worlds:

- **Website side**: An anonymous visitor clicked a Facebook ad, viewed 3 pages, and left
- **Business side**: Jane Smith paid $500 through Stripe

The `identify()` call bridges this gap. Once a visitor is identified (usually through a form fill), Atribu can trace the entire journey from ad click to payment.

---

## How it works

<Steps>
<Step>

### Visitor clicks an ad

A potential customer clicks your Facebook ad and lands on your website. Atribu assigns them an `anonymous_id` -- a random identifier tied to their device.

</Step>
<Step>

### Visitor fills out a form

The visitor fills out a contact form with their email. The tracker automatically calls `identify({ email: "jane@example.com" })`.

</Step>
<Step>

### Atribu creates a customer profile

Behind the scenes, Atribu creates a customer profile that links the `anonymous_id` to the email address. The visitor is no longer anonymous -- they are Jane Smith.

</Step>
<Step>

### A payment arrives later

When Stripe sends a payment event for `jane@example.com`, Atribu looks up the customer profile, finds the linked `anonymous_id`, and traces it back to the original ad click. Full attribution is achieved.

</Step>
</Steps>

---

## Automatic identification

In most cases, you do not need to call `identify()` yourself. Atribu's [auto-capture](/docs/tracking/auto-capture) handles it for you:

- **Form submissions** -- email and phone are extracted from form fields and `identify()` is called before the `lead_submitted` event fires
- **Booking widgets** -- GoHighLevel, Calendly, and Cal.com widget completions extract contact info and call `identify()` automatically

If your site uses standard HTML forms or supported booking widgets, identification happens without any extra code.

---

## Manual identification

For custom forms, login flows, or other scenarios where auto-capture does not apply, call `identify()` directly.

<Tabs items={["After a custom form", "After login/signup", "With phone number"]}>
<Tab value="After a custom form">
```js title="Identify after a custom form submit"
document.querySelector("#my-form").addEventListener("submit", function () {
  var email = document.querySelector("#email-field").value;
  var name = document.querySelector("#name-field").value;

  window.atribuTracker.identify({
    email: email,
    firstName: name.split(" ")[0],
    lastName: name.split(" ").slice(1).join(" "),
  });
});
```
</Tab>
<Tab value="After login/signup">
```js title="Identify after user logs in"
async function onLoginSuccess(user) {
  window.atribuTracker.identify({
    email: user.email,
    firstName: user.firstName,
    lastName: user.lastName,
    userId: user.id,
  });
}
```
</Tab>
<Tab value="With phone number">
```js title="Identify with phone number"
window.atribuTracker.identify({
  phone: "+1-555-123-4567",
  firstName: "Jane",
});
```
</Tab>
</Tabs>

### Accepted fields

| Field | Type | Description |
|-------|------|-------------|
| `email` | string | Email address (highest priority for matching) |
| `phone` | string | Phone number (second priority) |
| `firstName` | string | First name |
| `lastName` | string | Last name |
| `userId` | string | Your internal user ID |

---

## Identity priority

When matching visitors to customer profiles, Atribu uses this priority order:

1. **Email** -- the strongest identifier. If two events share the same email, they are the same person.
2. **Phone number** -- used when email is not available.
3. **External IDs** -- provider-specific IDs like Stripe customer IDs or GHL contact IDs.

Atribu normalizes all identifiers before matching (lowercasing emails, stripping phone formatting), so `Jane@Example.com` and `jane@example.com` are treated as the same person.

---

## Safe to call multiple times

Calling `identify()` more than once is safe and encouraged. If a visitor fills out a form with their email and later provides their phone number, calling `identify()` again adds the phone number to the existing profile without overwriting the email.

```js title="Progressive enrichment"
// First form: email only
window.atribuTracker.identify({ email: "jane@example.com" });

// Later: phone number captured from a different form
window.atribuTracker.identify({ phone: "+1-555-123-4567" });

// Both identifiers are now linked to the same customer profile
```

<Callout type="error" title="Critical: every conversion point needs identification">
Without `identify()`, server-side events like payments (Stripe, MercadoPago) and CRM events (GoHighLevel) cannot be attributed to ad clicks. Make sure every form, checkout flow, or conversion point on your site calls `identify()` -- either through auto-capture or manually.
</Callout>

---

## Next steps

<Cards>
  <Card title="Auto-Capture" href="/docs/tracking/auto-capture">
    See which forms and widgets are automatically captured
  </Card>
  <Card title="Custom Events" href="/docs/tracking/custom-events">
    Track custom actions beyond form submissions
  </Card>
  <Card title="Identity Resolution" href="/docs/concepts/identity-resolution">
    How the identity graph connects visitors, emails, and payments
  </Card>
  <Card title="Data Quality" href="/docs/features/data-quality">
    Monitor identification coverage and fix missing data
  </Card>
</Cards>

---

# Visitor Consent

The Atribu tracker keeps **one consent state** for each visitor, and it enforces it: when a visitor says no, the tracker stops saving what they said no to, and Atribu never sends their conversions to ad platforms.

---

## The two kinds of consent

| | What it covers | When denied |
|---|---|---|
| **analytics** | Atribu's own visitor id, session and stored visit history on the device | The id lives in memory for that page only. Nothing is saved to cookies or `localStorage`, nothing saved earlier is read back, and the same-origin collector does not set (and clears) its `atb_vid` cookie. |
| **ads** | Anything an ad platform reads: `_fbc` / `_fbp`, Google's `_gcl_aw`, TikTok's `_ttp`, stored click ids (`fbclid`, `gclid`, …), the checkout attribution token's click ids, the Meta Pixel, gtag and TikTok (`ttq`) bridges | No `_fbc` / `_fbp` is created or read, no click id is saved, the bridges leave your `fbq()` / `gtag()` / `ttq.track()` calls untouched, and the event is **never exported** to Meta, Google or TikTok (Conversion Sync shows it as skipped: `consent_denied`). |

The event itself is still recorded in Atribu, so your own dashboard keeps counting the visit and any sale. Every event carries the consent state it was sent under.

## Telling the tracker

```ts title="After your cookie banner"
atribuTracker.setConsent({ analytics: true, ads: false });
// npm: import { setConsent } from "@atribu/tracker";
```

- Only the keys you pass change — `setConsent({ ads: true })` keeps an earlier `analytics` answer.
- `marketing` is accepted as the older name for `ads` (Shopify's theme embed sends it).
- Google's two extra fields can be passed too: `ad_user_data`, `ad_personalization`.
- The decision is remembered on the device (remembering a choice is not tracking), and survives `reset()`.
- `atribuTracker.getConsent()` returns what the next event will carry.

Revoking consent mid-visit removes what the tracker had saved: `analytics: false` deletes the visitor id, session and stored visit history; `ads: false` removes the click ids from the stored visit. Cookies that belong to someone else — a `_fbp` your Meta Pixel created — are left for that tool's own consent handling.

## Consent mode

Each profile has a **Visitor consent** setting under **Settings → Tracking**:

- **Off** (default) — nothing changes for sites that never asked for it. The tracker only holds back when your site calls `setConsent(...)` with a denial.
- **Required** — nothing consent-gated happens until the visitor accepts. Until then: no saved identifiers, no ad cookies, no saved click ids, no bridges, no export. Google Consent Mode and IAB TCF answers are enforced automatically. If you use the combined snippet with the Meta Pixel, it also starts the Pixel in `fbq('consent', 'revoke')`; call `fbq('consent', 'grant')` when your banner grants ads consent.

The mode rides the tracker snippet (`window.ATRIBU_CONSENT_MODE = "required"`), so **re-install the snippet after changing it**. The server enforces "Required" on its own too: a browser event that arrives without a real ad grant — from an old cached snippet, say — is never exported.

With the npm package: `init({ trackingKey, consentMode: "required", consent: { analytics: true, ads: false } })`. A `consent` passed at `init` applies before the first identifier is written.

## Google Consent Mode v2

If your site uses Google Consent Mode (through a CMP, Google Tag Manager or `gtag('consent', 'default' | 'update', {...})`), the tracker reads it from `window.dataLayer` — no extra code.

| Consent Mode field | Atribu |
|---|---|
| `ad_storage` + `ad_user_data` | **ads** |
| `analytics_storage` | **analytics** |
| `ad_user_data`, `ad_personalization` | forwarded as-is on Google conversion uploads |

A `default` restricted to a `region` is ignored, because the tracker cannot know the visitor's region — under "Required" that means denied until an `update`.

In **Off** mode Consent Mode is recorded (and forwarded to Google) but not enforced. In **Required** mode it is enforced.

## IAB TCF v2.2

If a TCF CMP is on the page (`window.__tcfapi`), the tracker listens with `addEventListener` and reads the purpose consents once the CMP has loaded or the visitor has chosen:

| Atribu | TCF purposes |
|---|---|
| **ads** | 1 (store/access on a device) **and** 7 (measure ad performance) |
| **analytics** | 1 **and** (7 or 8) |
| Google `ad_user_data` | 1 and 7 |
| Google `ad_personalization` | 3 and 4 |

When GDPR does not apply (`gdprApplies: false`), TCF says nothing and the next source decides. Vendor consents are not read.

## Which answer wins

1. `setConsent(...)` (or `consent` at `init` / `window.ATRIBU_CONSENT`)
2. IAB TCF
3. Google Consent Mode
4. The profile's mode: **Off** → granted, **Required** → denied

## Google uploads

Every Google conversion upload carries Google's `consent` fields from the event: `ad_user_data` and `ad_personalization` as the page's Consent Mode / TCF / `setConsent` recorded them. A conversion with no browser consent record (a CRM stage change, a payment) is sent as granted, as before; one marked "never export" by your server (`consent: { marketing: false }` on `POST /api/v1/events`) is never uploaded at all.

## Shopify

Nothing changes on Shopify. The theme embed only loads the tracker once Shopify's customer-privacy API allows analytics, marketing and sale of data, and passes that answer on through `setConsent`. The Shopify Web Pixel runs inside Shopify's own privacy sandbox, so its events count as consented.

## Chile — Ley 21.719

Ley 21.719 (Chile's personal-data law, aligned with the GDPR) requires a lawful basis — usually consent — before personal data is processed for advertising, and lets a person withdraw it at any time, as easily as they gave it. What Atribu does to help you meet that:

- **Required** mode makes consent the precondition: until it is given, nothing that identifies the visitor is stored on their device and nothing is sent to ad platforms.
- Withdrawal takes effect on the next event, deletes what the tracker saved, and stops every later export of that visitor's conversions.
- The consent state is stored with each conversion the tracker records, so you can show what a visitor had agreed to when it happened.

You remain responsible for your banner's wording and for collecting consent itself; Atribu enforces the answer you pass it.

---

# Scheduler Redirects

When someone books a call, most schedulers can send them on to a thank-you page on your own site with their name, email and phone added to the link. With **scheduler identity capture** turned on, the Atribu tracker reads those details on the thank-you page, links the booking to the visit (and the ad click) that produced it, and then removes the details from the address bar.

It is **off by default** because it reads personal data out of a link. Nothing is read until you turn it on.

---

## Turn it on

1. Go to **Settings → Tracking → Tracking Behavior** and switch on **Identify visitors from scheduler redirects**. Save.
2. Copy the tracking snippet again and replace the one on your site. The new snippet contains `window.ATRIBU_ENABLE_SCHEDULER_IDENTITY = true;`.
3. Set up the redirect in your scheduler (below).

Both halves are needed: the tracker only reads the details when the snippet enables it, and Atribu discards them if the setting is off.

If you install with the [npm package](/docs/tracking/npm-package), pass `enableSchedulerIdentity: true` to `init()` instead of step 2.

---

## Calendly

1. Open the event type → **Booking page options** (in older layouts: **Confirmation page**).
2. Under **After booking**, choose **Redirect to an external site** and enter your thank-you page, e.g. `https://yoursite.com/thank-you`.
3. Tick **Pass event details to your redirected page**.

Calendly then adds `invitee_email`, `invitee_full_name`, `invitee_first_name`, `invitee_last_name`, `text_reminder_number` (the phone for SMS reminders) and `answer_1` … `answer_10` (your booking questions) to the link. Atribu uses the email, the phone and the name; from the answers it only takes a value that is itself an email or a phone number. ([Calendly's guide](https://calendly.com/help/how-to-redirect-invitees-to-another-site-after-booking))

---

## GoHighLevel

1. Open the calendar → **Forms & Payment** → **Confirmation page**, and choose **Redirect to URL**.
2. Enter your thank-you page with the contact's details as merge fields:

```text title="GoHighLevel redirect URL"
https://yoursite.com/thank-you?email={{contact.email}}&phone={{contact.phone}}&first_name={{contact.first_name}}&last_name={{contact.last_name}}
```

If your thank-you URL already has a `?`, start with `&email=` instead.

---

## Acuity Scheduling

Acuity does not add booking details to a redirect on its own. Use **Integrations → Custom conversion tracking** and paste a redirect that fills in Acuity's placeholders:

```html title="Acuity custom conversion tracking code"
<script>
  window.top.location.href =
    "https://yoursite.com/thank-you" +
    "?email=" + encodeURIComponent("%email%") +
    "&phone=" + encodeURIComponent("%phone%") +
    "&first_name=" + encodeURIComponent("%first%") +
    "&last_name=" + encodeURIComponent("%last%");
</script>
```

---

## What happens on the thank-you page

1. The tracker reads the email, phone and name from the link — before it records anything else.
2. It removes them from the address bar straight away (with `history.replaceState`), so they are not bookmarked, shared, re-sent on a refresh or passed on to the next page.
3. It sends one `identify` with the details inside the request body, never in a URL. Atribu links this visitor to the customer with that email or phone.

The booking itself still arrives from your scheduler's own integration (for example the GoHighLevel sync). This step is what connects it to the visit and the ad click.

An identify is only sent when a real email or phone number is present — a name on its own is not enough to recognise anyone. Placeholders that were never filled in (`{{contact.email}}`, `%email%`) are ignored.

<Callout type="warn" title="Showing the name on your thank-you page">
If your thank-you page reads `invitee_full_name` or `first_name` from the link to greet the visitor, read it before the Atribu tracker loads — once capture is on, the tracker removes those parameters when it starts.
</Callout>

---

## Privacy

- **Opt-in per profile.** With the setting off, the tracker reads nothing and leaves the address bar alone. Atribu also drops any scheduler identify from a profile that has not opted in, without storing it.
- **Never stored in a URL.** For every profile — opted in or not — Atribu removes `invitee_email`, `invitee_full_name`, `invitee_first_name`, `invitee_last_name`, `invitee_uuid`, `text_reminder_number`, `guests`, `assigned_to`, `answer_1` … `answer_10`, `email`, `phone`, `first_name`, `last_name` and `full_name` from every page URL and referrer before it is saved.
- Tell your visitors in your privacy notice that booking details are used to link their booking to their visit.

---

## Next steps

<Cards>
  <Card title="Identify Users" href="/docs/tracking/identify-users">
    Every other way a visitor becomes a known customer
  </Card>
  <Card title="Auto-Capture" href="/docs/tracking/auto-capture">
    Everything the tracker captures on its own
  </Card>
</Cards>

---

# Custom Events

Beyond automatic page views and form captures, you can track any action on your website as a custom event. Use custom events to measure button clicks, video plays, pricing page views, or anything else that matters to your business.

---

## Basic event tracking

Call `window.atribuTracker.track()` with an event name and optional properties:

```js title="Track a custom event"
window.atribuTracker.track("button_clicked", {
  payload: {
    button_name: "pricing_cta",
    page: "/pricing",
  },
});
```

The event name is the first argument. The second argument is an object with a `payload` key containing any properties you want to attach to the event.

---

## Revenue tracking

For events that involve money (purchases, upgrades, upsells), use `trackRevenue()` to include the amount and currency:

```js title="Track a purchase with revenue"
window.atribuTracker.trackRevenue("purchase", 99.99, "USD", {
  product: "Pro Plan",
  billing_cycle: "monthly",
});
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `eventName` | string | Name of the event |
| `amount` | number | Revenue amount |
| `currency` | string | ISO currency code (USD, EUR, MXN, etc.) |
| `data` | object | Optional additional properties |

---

## Page view tracking

Page views are tracked automatically on every page load. For single-page applications (SPAs) where the page changes without a full reload, you can fire page views manually:

```js title="Manual page view for SPA navigation"
window.atribuTracker.page();
```

<Callout type="info" title="SPA auto-detection">
Atribu automatically detects `pushState` and `replaceState` navigation in most SPAs (React, Next.js, Vue, etc.) and fires page views without manual calls. Only use manual page views if auto-detection is not working for your setup.
</Callout>

---

## Event naming best practices

Good event names make your reports easier to read and your conversion goals easier to set up.

**Use `snake_case`**

```js title="Good event names"
window.atribuTracker.track("video_played", { payload: { video_id: "abc" } });
window.atribuTracker.track("pricing_viewed", { payload: { plan: "pro" } });
window.atribuTracker.track("checkout_started", { payload: { cart_value: 149 } });
```

**Be specific**

| Instead of | Use |
|-----------|-----|
| `click` | `pricing_cta_clicked` |
| `submit` | `contact_form_submitted` |
| `view` | `case_study_viewed` |
| `purchase` | `subscription_purchased` |

**Stay consistent**

Pick a naming convention and use it everywhere. If your first event is `form_submitted`, do not switch to `formSubmit` or `FormSubmitted` later.

---

## Using custom events as conversion goals

Any custom event can be turned into a conversion goal in Atribu. This means the attribution engine will track which ads and campaigns drive that specific action.

<Steps>
<Step>

### Track the event on your site

```js title="Track a custom conversion event"
window.atribuTracker.track("demo_requested", {
  payload: {
    plan_interest: "enterprise",
  },
});
```

</Step>
<Step>

### Create a conversion definition in Atribu

Go to **Settings > Conversions** and create a new conversion definition. Set the **Source event name** to `demo_requested`, spelled exactly as you track it. From then on, every `demo_requested` event becomes a conversion that Atribu attributes to your campaigns, and that a sync rule can send to Meta, Google or TikTok.

</Step>
</Steps>

<Callout type="warn" title="Rules for custom conversion goals">
- **Lowercase `snake_case` only**: letters, digits and underscores, up to 120 characters. A name with spaces or dashes stays a plain event and never becomes a conversion.
- **Not a revenue goal**: a custom event is sent from the browser, so it counts as a pipeline goal, never as revenue. A definition with the Cash or Gross revenue type ignores custom events. Revenue comes from your payment or store integration, or from the built-in `purchase` event.
- **Built-in names are reserved**: names Atribu already uses (`purchase`, `lead_submitted`, `payment_received` and the rest) cannot be custom conversions. Track them with their own event instead.
- **From the moment the definition exists**: events tracked before you created the definition are not counted retroactively.
</Callout>

<Callout type="info" title="Automatic events work too">
You can also create conversion definitions for auto-captured events like `lead_submitted` and `appointment_booked`. Any event that flows through the tracker can become an attribution goal.
</Callout>

---

## Additional tracking methods

### Set a user ID

If your app has its own user ID system, you can link it to the Atribu visitor:

```js title="Set a custom user ID"
window.atribuTracker.setUserId("user_12345");
```

### Observe element impressions

Track when a specific element becomes visible on screen (useful for measuring ad impressions or content visibility):

```js title="Track element visibility"
var cleanup = window.atribuTracker.observeImpression("#hero-banner", {
  payload: { banner_variant: "spring_sale" },
});

// Call cleanup() to stop observing
```

The event fires once when the element is at least 50% visible. You can customize the threshold:

```js title="Custom visibility threshold"
window.atribuTracker.observeImpression("#cta-section", {}, {
  threshold: 0.75, // Fire when 75% visible
});
```

---

## Next steps

<Cards>
  <Card title="Identify Users" href="/docs/tracking/identify-users">
    Connect anonymous visitors to real people
  </Card>
  <Card title="UTM Parameters" href="/docs/tracking/utm-parameters">
    Tag your traffic sources for accurate attribution
  </Card>
  <Card title="Conversion Goals" href="/docs/settings/goals">
    Turn custom events into conversion goals for attribution
  </Card>
</Cards>

---

# npm Package (@atribu/tracker)

The `@atribu/tracker` npm package is the official TypeScript SDK for Atribu. It bundles the full tracker runtime with type-safe configuration, framework-agnostic design, and automatic SSR safety.

## Installation

```bash title="Install"
npm install @atribu/tracker
```

## Quick start

<Tabs items={["React", "Next.js", "Vue 3", "Vanilla JS"]}>
<Tab value="React">
```tsx title="src/App.tsx"
import { useEffect } from "react";
import { init } from "@atribu/tracker";

function App() {
  useEffect(() => {
    init({ trackingKey: "trk_live_your_key" });
  }, []);

  return <div>Your app</div>;
}
```
</Tab>
<Tab value="Next.js">
```tsx title="src/components/AtribuProvider.tsx"
"use client";

import { useEffect } from "react";
import { init } from "@atribu/tracker";

export function AtribuProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    init({ trackingKey: "trk_live_your_key" });
  }, []);

  return <>{children}</>;
}
```

Then wrap your root layout:
```tsx title="src/app/layout.tsx"
import { AtribuProvider } from "@/components/AtribuProvider";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <AtribuProvider>{children}</AtribuProvider>
      </body>
    </html>
  );
}
```
</Tab>
<Tab value="Vue 3">
```vue title="src/App.vue"
<script setup>
import { onMounted } from "vue";
import { init } from "@atribu/tracker";

onMounted(() => {
  init({ trackingKey: "trk_live_your_key" });
});
</script>
```
</Tab>
<Tab value="Vanilla JS">
```html title="index.html"
<script type="module">
  import { init } from "@atribu/tracker";
  init({ trackingKey: "trk_live_your_key" });
</script>
```
</Tab>
</Tabs>

## Configuration

All options passed to `init()`:

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `trackingKey` | `string` | **required** | Your ingest key from Settings > Tracking |
| `apiHost` | `string` | `window.location.origin` | Origin for the collect endpoint |
| `trackingEndpoint` | `string` | `{apiHost}/api/tracking/collect` | Full URL override for tracking |
| `interceptMetaFbq` | `boolean` | `true` | Intercept Meta Pixel `fbq()` calls for server-side deduplication |
| `metaBridgePageview` | `boolean` | `false` | Also mirror Meta PageView events |
| `sessionTimeoutMinutes` | `number` | `30` | Session inactivity timeout (1-120 min) |
| `sessionMode` | `string` | `"inactivity_only"` | `"inactivity_only"` or `"inactivity_or_source_change"` |
| `heartbeatIntervalSeconds` | `number \| false` | `60` | Engagement heartbeat interval (crash-recovery checkpoint). `0` or `false` to disable |
| `enableEngagement` | `boolean` | `false` | Scroll depth, time on page, and a per-minute heartbeat |
| `enableClickQuality` | `boolean` | `false` | Track rage clicks and dead clicks |
| `enableWebVitals` | `boolean` | `false` | Capture Core Web Vitals (LCP, FCP, CLS, INP, TTFB) |
| `enableCtaTracking` | `boolean` | `false` | Auto-detect and track CTA button visibility |
| `enableVideoTracking` | `boolean` | `false` | Auto-detect `<video>` elements and track milestones |
| `enableGtagBridge` | `boolean` | `false` | Share a transaction id between browser `gtag()`/`dataLayer` conversions and Atribu's Google upload |
| `enableTtqBridge` | `boolean` | `false` | Share an event id between TikTok pixel (`ttq.track`) events and Atribu's TikTok Events API export |
| `enableSchedulerIdentity` | `boolean` | `false` | Identify the visitor from a Calendly / GoHighLevel / Acuity thank-you redirect and strip those parameters from the URL. Also needs the profile setting — see [Scheduler Redirects](/docs/tracking/scheduler-redirects) |
| `ignoredPages` | `string[]` | — | URL patterns to skip tracking (e.g., `["/admin/*"]`) |
| `customProperties` | `object \| function` | — | Static properties or function merged into every event |
| `transformRequest` | `function` | — | Middleware to modify or suppress events before sending |

<Callout type="warn" title="Four of these five are dropped server-side no matter what">
  `enableEngagement`, `enableClickQuality`, `enableWebVitals`, and
  `enableVideoTracking` all emit the browser event under the name
  `engagement`. Atribu's ingest unconditionally drops every event named
  `engagement` before it is enriched or stored — the dashboard surfaces that
  data used to feed were retired. There is no server-side setting that brings
  it back; turning on any of these four client flags produces events the
  server silently discards. `enableCtaTracking` is different: it emits
  `view_content`, a normal event type with no such drop, so turning it on
  **does** create real, attribution-eligible events.
</Callout>

### Google gtag bridge

```ts title="Enable the gtag/dataLayer transaction id bridge"
init({
  trackingKey: "trk_live_...",
  enableGtagBridge: true,
});
```

Google only deduplicates a browser `gtag()` conversion against a
server-uploaded one when both carry the exact same `transaction_id`. With
`enableGtagBridge: true`, the tracker:

- **Captures** the `transaction_id` from an existing `gtag('event', 'purchase' | 'conversion', {...})`
  call or a GTM-style `dataLayer.push({ event: 'purchase', ecommerce: { transaction_id } })`
  and reuses it as the id for Atribu's own tracked event, so the server-side
  Google upload (Data Manager `transactionId`) sends the identical value.
- **Generates and injects** a transaction id when the call has none, pushing
  it back into the outgoing gtag/dataLayer call before it proceeds, so the
  conversion Google actually receives from the browser carries the same id
  the server upload will use.

An existing `transaction_id` is never overwritten. Off by default — most
sites don't run `gtag.js` at all, and this only ever observes calls a page is
already making.

### TikTok pixel bridge

```ts title="Enable the TikTok pixel (ttq) event id bridge"
init({
  trackingKey: "trk_live_...",
  enableTtqBridge: true,
});
```

With the script tag, set `window.ATRIBU_ENABLE_TTQ_BRIDGE = true` before the
tracker loads.

TikTok deduplicates a TikTok pixel event against an Events API event only when
both carry the same `event_id`. When a Conversion Sync rule sends conversions
to TikTok, Atribu's server-side event uses Atribu's own event id — this bridge
puts that same id on the browser pixel event. With `enableTtqBridge: true`, the
tracker:

- **Fires the pixel for Atribu's conversions.** When Atribu tracks a conversion
  (a form submit, a purchase, a booking…) and the page has the TikTok pixel,
  it calls `ttq.track(event, { value, currency }, { event_id })` with Atribu's
  event id. Purchases go out as `CompletePayment` and leads as `SubmitForm`,
  the same names the server-side event uses by default.
- **Captures** the `event_id` from a `ttq.track(...)` call your site already
  makes and reuses it as the id of Atribu's own event, so the server-side event
  sends the identical value.
- **Links or injects** an id when your call has none: if Atribu tracked the same
  conversion a moment earlier (for example from the same click), its id is
  injected into your call; otherwise a new one is generated, injected, and used
  for Atribu's event.

An existing `event_id` is never overwritten, and when your own `ttq.track`
fires first Atribu does not fire a second pixel event for the same moment. The
bridge also sends TikTok's `_ttp` cookie so the server-side event can be matched
to the browser. If a rule sets a custom TikTok event name, use that same name in
any `ttq.track` call you make, or the two events will not pair. Off by default —
it reads from and fires into your TikTok pixel, so it must be turned on
deliberately.

### Custom properties

```ts title="Static properties"
init({
  trackingKey: "trk_live_...",
  customProperties: {
    environment: "production",
    appVersion: "2.1.0",
  },
});
```

```ts title="Dynamic properties (function)"
init({
  trackingKey: "trk_live_...",
  customProperties: (ctx) => ({
    currentPath: ctx.path,
    isLoggedIn: !!localStorage.getItem("token"),
  }),
});
```

### Transform request (middleware)

```ts title="Filter out events"
init({
  trackingKey: "trk_live_...",
  transformRequest: (event) => {
    // Suppress events from admin pages
    if (event.path?.startsWith("/admin")) return null;
    // Add custom header
    event.payload.buildId = "abc123";
    return event;
  },
});
```

## Tracking events

```ts title="Basic event"
import { track } from "@atribu/tracker";

track("button_clicked", { buttonName: "pricing_cta" });
```

```ts title="Revenue event"
import { trackRevenue } from "@atribu/tracker";

trackRevenue("purchase", 99.99, "USD", { plan: "Pro" });
```

```ts title="Self-describing event (with schema)"
import { trackSelfDescribing } from "@atribu/tracker";

trackSelfDescribing({
  eventSchema: "com.atribu.checkout",
  schemaVersion: 1,
  payload: { step: "payment", method: "credit_card" },
});
```

## Identifying users

```ts title="Identify after form submission"
import { identify } from "@atribu/tracker";

identify({
  email: "jane@example.com",
  firstName: "Jane",
  lastName: "Smith",
  phone: "+1-555-0123",
});
```

<Callout type="error" title="Critical for attribution">
Without `identify()`, anonymous visitors cannot be linked to payments or CRM events. Call it on every form submission, login, or signup. See [User Identification](/docs/tracking/identify-users) for details.
</Callout>

## Consent management

```ts title="Set consent after cookie banner"
import { setConsent } from "@atribu/tracker";

// After user accepts analytics cookies but not ads
setConsent({ analytics: true, ads: false });
```

Consent is enforced: `ads: false` stops `_fbc`/`_fbp`, saved click ids, the Meta/gtag bridges and every export to ad platforms; `analytics: false` keeps identifiers in memory for the page only. Pass `consentMode: "required"` (and optionally an initial `consent`) to `init()` to deny everything until a grant; Google Consent Mode v2 and IAB TCF are read automatically. `getConsent()` returns the current state. Details: [Visitor Consent](/docs/tracking/consent).

## Impressions

```ts title="Track when an element becomes visible"
import { observeImpression } from "@atribu/tracker";

// CSS selector
const cleanup = observeImpression("#hero-banner", { section: "hero" });

// DOM element
const el = document.querySelector(".pricing-card");
const cleanup2 = observeImpression(el, { plan: "pro" }, { threshold: 0.8 });

// Cleanup on unmount
cleanup();
cleanup2();
```

## Lifecycle

```ts title="Flush and reset"
import { flush, reset } from "@atribu/tracker";

// Force-send all queued events (before navigation)
flush();

// Clear all stored state (on logout)
reset();
```

## Auto-captured events

The package automatically captures these events with zero configuration:

| Category | Events |
|----------|--------|
| **Navigation** | Page views, SPA navigation (pushState, popstate, hashchange) |
| **Sessions** | Session start/end, configurable timeout |
| **Engagement** | Scroll depth, time on page, heartbeat |
| **Links** | Outbound link clicks, file downloads (PDF, ZIP, etc.) |
| **Forms** | Form submissions with email/phone extraction |
| **Bookings** | GHL, Calendly, Cal.com widget completions |
| **Meta Pixel** | `fbq()` interception for server-side dedup |
| **Google gtag** | `gtag()`/`dataLayer` transaction id bridge for server-side dedup (optional) |
| **TikTok pixel** | `ttq.track` event id bridge for Events API dedup (optional) |
| **Stripe** | Checkout completion detection (`?session_id=cs_*`) |
| **GHL Forms** | `fetch()` interception for div-based forms |
| **Click Quality** | Rage clicks (3+ rapid), dead clicks (optional) |
| **Web Vitals** | LCP, FCP, CLS, INP, TTFB (optional) |
| **CTA Visibility** | Action button/link visibility tracking (optional) |
| **Video** | `<video>` play/pause/milestones (optional) |
| **Errors** | Uncaught JavaScript errors |
| **Bot Detection** | Filters bots, tags AI agents (ChatGPT, Claude, etc.) |

## Script tag vs npm

| | npm Package | Script Tag |
|---|---|---|
| **Install** | `npm install @atribu/tracker` | `<script src="..."></script>` |
| **TypeScript** | Full type safety | No types |
| **Frameworks** | React, Next.js, Vue, Svelte | Vanilla HTML only |
| **SSR** | Built-in no-op client | Client-side only |
| **Config** | Typed `init()` object | Window variables |
| **Module** | ESM + CommonJS | IIFE global |
| **Functionality** | Identical | Identical |

## TypeScript support

All types are exported:

```ts title="Import types"
import type {
  AtribuConfig,
  AtribuClient,
  TrackOptions,
  IdentifyInput,
  ConsentPayload,
  TrackingEvent,
} from "@atribu/tracker";
```

## SSR safety

When `window` is undefined (Node.js, Next.js server components, SSR), `init()` returns a **silent no-op client**. All methods (`track`, `identify`, etc.) are safe to call — they simply do nothing server-side and activate once the code runs in the browser.

```ts title="Safe in any environment"
import { init, track } from "@atribu/tracker";

// This works in SSR — returns no-op client, no errors
const client = init({ trackingKey: "trk_live_..." });

// This is safe server-side — silently ignored
track("page_loaded");
```

## Related

<Cards>
  <Card title="Install Tracking" href="/docs/getting-started/install-tracker">
    Script tag installation for non-npm setups
  </Card>
  <Card title="Framework Guides" href="/docs/tracking/framework-guides">
    Detailed setup for React, Next.js, Vue, and Svelte
  </Card>
  <Card title="Custom Events" href="/docs/tracking/custom-events">
    Track custom actions and revenue events
  </Card>
</Cards>

---

# Framework Guides

Detailed setup instructions for each framework. The `@atribu/tracker` package works with any JavaScript framework thanks to its SSR-safe design.

## React

Create a provider component that initializes the tracker once:

```tsx title="src/components/AtribuProvider.tsx"
import { useEffect } from "react";
import { init } from "@atribu/tracker";

export function AtribuProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    init({
      trackingKey: "trk_live_your_key",
      // Optional: customize behavior
      sessionTimeoutMinutes: 30,
      // Defaults to false. Atribu's ingest currently drops these events
      // unconditionally server-side (the dashboard surface they fed was
      // retired) — enabling it produces no visible data today. See the
      // Configuration table on the npm Package page.
      enableWebVitals: false,
    });
  }, []);

  return <>{children}</>;
}
```

```tsx title="src/main.tsx"
import { AtribuProvider } from "./components/AtribuProvider";

function App() {
  return (
    <AtribuProvider>
      <YourApp />
    </AtribuProvider>
  );
}
```

### Tracking events in components

```tsx title="src/components/PricingButton.tsx"
import { track } from "@atribu/tracker";

export function PricingButton() {
  return (
    <button onClick={() => track("pricing_clicked", { plan: "pro" })}>
      View Pricing
    </button>
  );
}
```

### Identifying users after form submission

```tsx title="src/components/ContactForm.tsx"
import { identify } from "@atribu/tracker";

export function ContactForm() {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const data = new FormData(e.currentTarget);

    identify({
      email: data.get("email") as string,
      firstName: data.get("name") as string,
    });

    // Submit form...
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Name" />
      <input name="email" type="email" placeholder="Email" />
      <button type="submit">Submit</button>
    </form>
  );
}
```

---

## Next.js (App Router)

<Callout type="warn" title="Client component required">
The tracker uses browser APIs (`window`, `localStorage`). It must be initialized in a Client Component with the `"use client"` directive.
</Callout>

```tsx title="src/components/AtribuProvider.tsx"
"use client";

import { useEffect } from "react";
import { init } from "@atribu/tracker";

export function AtribuProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    init({ trackingKey: "trk_live_your_key" });
  }, []);

  return <>{children}</>;
}
```

```tsx title="src/app/layout.tsx"
import { AtribuProvider } from "@/components/AtribuProvider";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <AtribuProvider>{children}</AtribuProvider>
      </body>
    </html>
  );
}
```

<Callout type="info" title="SSR safe">
If you accidentally import `@atribu/tracker` in a Server Component, `init()` returns a silent no-op client. No errors, no crashes — it simply does nothing until running in the browser.
</Callout>

---

## Vue 3

```vue title="src/App.vue"
<script setup lang="ts">
import { onMounted } from "vue";
import { init } from "@atribu/tracker";

onMounted(() => {
  init({ trackingKey: "trk_live_your_key" });
});
</script>

<template>
  <RouterView />
</template>
```

### Tracking events

```vue title="src/components/PricingCard.vue"
<script setup lang="ts">
import { track } from "@atribu/tracker";

function handleClick() {
  track("pricing_clicked", { plan: "pro" });
}
</script>

<template>
  <button @click="handleClick">View Pricing</button>
</template>
```

---

## Svelte

```svelte title="src/App.svelte"
<script>
  import { onMount } from "svelte";
  import { init } from "@atribu/tracker";

  onMount(() => {
    init({ trackingKey: "trk_live_your_key" });
  });
</script>

<slot />
```

---

## Vanilla JavaScript

```html title="index.html"
<script type="module">
  import { init, track, identify } from "@atribu/tracker";

  init({ trackingKey: "trk_live_your_key" });

  // Track a custom event
  document.querySelector("#cta").addEventListener("click", () => {
    track("cta_clicked");
  });

  // Identify after form submit
  document.querySelector("form").addEventListener("submit", (e) => {
    const email = e.target.querySelector('[name="email"]').value;
    identify({ email });
  });
</script>
```

---

## SPA navigation

The tracker automatically detects Single Page App navigation via:
- `history.pushState` and `history.replaceState` interception
- `popstate` event listener
- `hashchange` event listener

Each navigation fires a new `page_view` event and resets engagement tracking (scroll depth, time on page). No additional configuration needed.

---

## SSR environments

In server-side rendering (Next.js, Nuxt, SvelteKit SSR), the tracker handles the missing `window` gracefully:

1. `init()` detects `typeof window === "undefined"`
2. Returns a **no-op client** where all methods (`track`, `identify`, etc.) are empty functions
3. No errors thrown, no `window is not defined` crashes
4. When the code later runs in the browser, `init()` activates normally

This means you can safely import and call tracker functions anywhere — server or client.

## Related

<Cards>
  <Card title="npm Package" href="/docs/tracking/npm-package">
    Full API reference and configuration options
  </Card>
  <Card title="Install Tracking" href="/docs/getting-started/install-tracker">
    Script tag installation (alternative to npm)
  </Card>
</Cards>

---

# Custom Domain

Use your own domain for tracking to improve data accuracy and avoid ad blockers.

---

## Why use a custom domain

Ad blockers and browser privacy features sometimes block third-party tracking scripts. When your tracking runs on a third-party domain, some visitors' data may be lost.

By setting up a custom subdomain (e.g., `t.yourdomain.com`), the tracker becomes a **first-party script** -- it runs on your own domain, just like the rest of your website. Most ad blockers do not block first-party requests, so you capture more accurate data.

<Callout type="warn" title="Not ITP-proof">
A custom tracking domain gets past ad blockers, but Safari and iPhones still forget returning visitors after 7 days: the subdomain points at Atribu's servers, not your site's, and Safari limits cookies from a different network. To keep visitors identified for up to 400 days, use a [same-origin collector](/docs/tracking/same-origin-collector).
</Callout>

---

## Setup

<Steps>
<Step>

### Open tracking settings

Go to **Settings > Tracking** in your Atribu dashboard.

</Step>
<Step>

### Enter your custom subdomain

Choose a subdomain on your domain, for example:

```text title="Example subdomains"
t.yourdomain.com
track.yourdomain.com
data.yourdomain.com
```

Any subdomain works. Keep it short -- `t` is a popular choice.

</Step>
<Step>

### Add the DNS record

Add a **CNAME** record at your DNS provider pointing your subdomain to Atribu's tracking server:

| Type | Name | Target |
|------|------|--------|
| CNAME | `t` | `collect.atribu.app` |

The exact steps depend on your DNS provider (Cloudflare, GoDaddy, Namecheap, etc.). Look for "Add DNS record" or "Manage DNS" in your provider's dashboard.

</Step>
<Step>

### Verify the connection

Click **Verify** in Atribu. The system checks that your DNS record is properly configured and points to the correct target.

Once verified, your tracking snippet automatically uses the custom domain for all data collection.

</Step>
</Steps>

---

## Domain Connect (automatic setup)

If your DNS provider supports the Domain Connect protocol, Atribu can set up the DNS record for you automatically.

<Steps>
<Step>

### Enter your subdomain

Type your desired subdomain in the tracking settings.

</Step>
<Step>

### Click "Auto Setup"

Atribu checks if your DNS provider supports Domain Connect. If it does, you will see an "Auto Setup" option.

</Step>
<Step>

### Authorize the DNS change

You will be redirected to your DNS provider to approve the CNAME record. Once approved, the record is created automatically and verification happens right away.

</Step>
</Steps>

<Callout type="info" title="Domain Connect support">
Domain Connect is supported by many major DNS providers including GoDaddy, 1&1 IONOS, and others. If your provider does not support it, use the manual CNAME setup described above.
</Callout>

---

## A harder-to-block address

Some ad blockers block any address named like a tracker, on every website — including `atribu-tracker.js` and `/api/tracking/collect` on your own tracking domain. In **Settings > Tracking**, turn on **Harder-to-block tracker address**. Atribu gives your profile a random-looking address (for example `k3v9x2q7mf0a`), and your tracking code then loads from `https://track.yourdomain.com/k3v9x2q7mf0a.js` and sends events to `https://track.yourdomain.com/k3v9x2q7mf0a`.

After turning it on, copy your tracking code again and replace the old one. The old address keeps working, so nothing breaks while you update. If blockers start matching the new address, click **New address** to get another.

---

## Troubleshooting

### "DNS not verified" after adding the record

DNS changes can take time to propagate. In most cases it takes 1-5 minutes, but it can take up to **48 hours** depending on your DNS provider and TTL settings. Wait a few minutes and click **Verify** again.

### Using Cloudflare

If you use Cloudflare, you have two options:

- **DNS Only (gray cloud)** -- The CNAME resolves normally. Verification works as expected.
- **Proxied (orange cloud)** -- Cloudflare flattens the CNAME to an A/AAAA record. Atribu supports this and verifies by checking that the resolved IP addresses match.

Both options work. No special configuration is needed.

### Removing a custom domain

Go to **Settings > Tracking**, find your custom domain, and click **Remove**. Your tracking snippet will revert to using the default Atribu domain.

---

## Next steps

<Cards>
  <Card title="How Tracking Works" href="/docs/tracking/how-tracking-works">
    Understand visitors, sessions, and cookies
  </Card>
  <Card title="UTM Parameters" href="/docs/tracking/utm-parameters">
    Tag your traffic sources for accurate attribution
  </Card>
  <Card title="Data Quality" href="/docs/features/data-quality">
    Monitor tracking coverage and fix attribution gaps
  </Card>
</Cards>

---

# Same-Origin Collector

Serve Atribu's tracker from a path on your own website (for example `yourstore.com/atb`) so Safari and iPhones keep recognizing returning visitors for up to **400 days** instead of **7**.

---

## Why this matters

Safari's Intelligent Tracking Prevention (ITP) deletes anything a script stores in the browser after 7 days — and after just 24 hours when the visitor arrived from an ad click. Roughly half of most stores' traffic is Safari or an in-app browser on iPhone. Without this setup, someone who clicks your ad today and buys two weeks later looks like a brand-new visitor, and the sale cannot be tied back to the ad.

A [custom tracking domain](/docs/tracking/custom-domain) (`t.yourstore.com`) does **not** fix this. It gets past ad blockers, but Safari also limits cookies set by a server on a different network from your site, and a CNAME to `collect.atribu.app` is exactly that.

The same-origin collector is the fix: a small proxy on **your own site** forwards tracking requests to Atribu, and Atribu's answer sets a secure, server-side cookie (`atb_vid`) that Safari leaves alone.

| Setup | Blocks ad blockers | Survives Safari's 7-day limit |
|---|---|---|
| Default (`atribu.app`) | No | No |
| Custom tracking domain (CNAME) | Yes | **No** |
| Same-origin collector | Yes | **Yes** |

Your dashboard shows which one a profile is on under **Settings > Tracking > Domains**.

---

## Setup with a Cloudflare Worker

You need your site on Cloudflare (orange-cloud proxied). Any plan works, including Free.

<Steps>
<Step>

### Save your addresses in Atribu

Go to **Settings > Tracking > Domains > Same-origin collector** and enter:

- **Your site** — `https://yourstore.com`
- **Collector path on your site** — `https://yourstore.com/atb`

Click **Save**. Atribu shows a **proxy secret** once. Copy it now.

</Step>
<Step>

### Create the Worker

In Cloudflare, open **Workers & Pages > Create > Worker**, name it `atribu-collector`, and replace its code with [`worker.js`](https://github.com/AtribuCore/atribu/blob/main/tracker/proxy/cloudflare-worker/worker.js). Deploy.

Then, under the Worker's **Settings > Variables and Secrets**, add:

| Name | Type | Value |
|---|---|---|
| `ATRIBU_PROXY_SECRET` | Secret | the secret from step 1 |
| `ATRIBU_PATH_PREFIX` | Text | `/atb` (only if you chose a different path) |

</Step>
<Step>

### Add the route

Under the Worker's **Settings > Domains & Routes**, add a route on your site's zone:

```text
yourstore.com/atb/*
```

Use the exact hostname your store is served from (`www.yourstore.com/atb/*` if that is your main address).

</Step>
<Step>

### Check the installation

Back in Atribu, click **Check installation**. It passes when:

- the path answers through your Worker and reaches Atribu with the right secret, and
- it answers from the same network as your site (Safari's condition for keeping the cookie).

</Step>
<Step>

### Replace your tracking code

Copy the **Tracking code for this mode** shown in Atribu and replace your current Atribu snippet with it. It loads the tracker from `yourstore.com/atb/atribu-tracker.js`.

</Step>
</Steps>

<Callout type="info" title="Visitors already on your site keep their history">
The first time a returning visitor reaches the new collector, their current Atribu ID becomes the long-lived one. Nobody is reset.
</Callout>

### Command-line deploy

If you deploy Workers with Wrangler, copy [`wrangler.toml.example`](https://github.com/AtribuCore/atribu/blob/main/tracker/proxy/cloudflare-worker/wrangler.toml.example) next to `worker.js`, replace `your-shop.com`, then run `npx wrangler secret put ATRIBU_PROXY_SECRET` and `npx wrangler deploy`.

### Sharing the cookie across subdomains

By default the cookie belongs to the exact hostname the visitor is on. If your store spans `www.yourstore.com` and `shop.yourstore.com`, set `ATRIBU_COOKIE_DOMAIN` to `yourstore.com` in the Worker.

### Using the harder-to-block address

If you turned on **Harder-to-block tracker address** in **Settings > Tracking**, also set `ATRIBU_PATH_ALIAS` in the Worker to the address shown there (for example `k3v9x2q7mf0a`). The Worker then also serves the tracker at `yourstore.com/atb/k3v9x2q7mf0a.js` and accepts events at `yourstore.com/atb/k3v9x2q7mf0a`, which ad blockers do not recognise. The standard addresses keep working.

---

## What the proxy sends, and what it does not

- It forwards only what the tracker needs: the event, the browser's user agent and language, the page address, and the visitor's IP address (so location and fraud checks keep working).
- It forwards **only** Atribu's own `atb_vid` cookie. Your store's other cookies (logins, carts, sessions) never leave your site.
- The proxy secret stays inside the Worker. It is never sent to browsers, and Atribu stores only a fingerprint of it.
- `atb_vid` holds a random visitor ID — no personal data. Signing out a visitor with `atribuTracker.reset()` also replaces it.

---

## For developers: the proxy contract

Any reverse proxy on your own origin can do the same job. If your site runs on **Next.js / Vercel**, see the [dedicated recipe](/docs/tracking/same-origin-collector-nextjs) — a small Route Handler using `@atribu/tracker/next`, no Cloudflare account needed. For anything else, a proxy must:

1. Serve `GET <path>/atribu-tracker.js` from `https://www.atribu.app/atribu-tracker.js`. Optionally prepend `window.ATRIBU_TRACKING_ENDPOINT = location.origin + "<path>/api/tracking/collect";` and, when the request has an `atb_vid` cookie, `window.ATRIBU_SERVER_ANON_ID = "<its value>";`.
2. Forward `<path>/api/tracking/*` to `https://www.atribu.app/api/tracking/*` with the same method, query and body, plus these request headers:

| Header | Value |
|---|---|
| `x-atribu-proxy-secret` | the profile's proxy secret |
| `x-atribu-client-ip` | the visitor's IP as your edge saw it |
| `x-atribu-proxy-host` | the hostname the browser requested |
| `x-atribu-proxy` | `<your-proxy-name>/1` |
| `x-atribu-cookie-domain` | optional — a parent domain for `atb_vid` |
| `x-atribu-client-country` | recommended — the visitor's country (ISO 3166-1 alpha-2, e.g. `CL`) as your platform resolved it |
| `x-atribu-client-region` | recommended — the visitor's region code (e.g. `RM`); may be percent-encoded |
| `x-atribu-client-city` | recommended — the visitor's city; may be percent-encoded (e.g. `Vi%C3%B1a%20del%20Mar`) |
| `cookie` | `atb_vid=<value>` only — never the site's other cookies |

3. Never pass through `x-atribu-*` or `x-forwarded-for` headers that came from the browser.
4. Return Atribu's response unchanged — including its `Set-Cookie: atb_vid=…` header.

Optionally (harder-to-block address): also serve the script at `<path>/<alias>.js` and forward `<path>/<alias>` to `/api/tracking/collect` and `<path>/<alias>/<name>` to `/api/tracking/<name>` (for `identity-signal`, `verification/beacon`, `proxy-probe`). The paths above must keep working either way.

Atribu trusts the forwarded IP, cookie and location **only** when the secret matches the profile the tracking key belongs to. Without it, the request is processed like any other: no cookie is set.

**Visitor location.** Your proxy's request reaches Atribu from your proxy's server, so the location Atribu would otherwise see is your server's region (a Vercel function in São Paulo, a Cloudflare datacenter in Virginia), not your visitor's. For a trusted request Atribu therefore discards it, and uses only the `x-atribu-client-country` / `-region` / `-city` headers you forward. Take them from your platform's own visitor-location data: on Vercel, `x-vercel-ip-country`, `x-vercel-ip-country-region` and `x-vercel-ip-city`; in a Cloudflare Worker, `request.cf.country`, `request.cf.regionCode` and `request.cf.city`. Never copy them from a browser-supplied header. Without them the visitor's location is recorded as unknown, which also means Meta's Conversions API receives no country or city for them.

---

## Next steps

<Cards>
  <Card title="Next.js / Vercel Recipe" href="/docs/tracking/same-origin-collector-nextjs">
    Skip the Worker — a Route Handler does the same job
  </Card>
  <Card title="WordPress / WooCommerce Plugin" href="/docs/tracking/wordpress-plugin">
    Install this as a plugin, with no code editing, and get WooCommerce cart/checkout/purchase events
  </Card>
  <Card title="Custom Domain" href="/docs/tracking/custom-domain">
    Get past ad blockers with a tracking subdomain
  </Card>
  <Card title="How Tracking Works" href="/docs/tracking/how-tracking-works">
    Understand visitors, sessions, and cookies
  </Card>
</Cards>

---

# Same-Origin Collector for Next.js / Vercel

If your site runs on **Next.js** (App Router) — on Vercel or anywhere else — you can add a small Route Handler instead of deploying a [Cloudflare Worker](/docs/tracking/same-origin-collector) to get the same result: visitors stay identified on Safari and iPhone for up to **400 days** instead of **7**.

<Callout type="info" title="Read this first">
This page assumes you already read [Same-Origin Collector](/docs/tracking/same-origin-collector) — it explains **why** this exists (Safari's ITP cap, and why a custom tracking domain CNAME does not fix it). This page is the Next.js-specific "how".
</Callout>

---

## Why a rewrite is not enough

A plain `next.config.ts` [`rewrites()`](https://nextjs.org/docs/app/api-reference/config/next-config-js/rewrites) entry forwards the request and passes cookies through, but it cannot **add** a header. Atribu only trusts the forwarded visitor IP and issues the `atb_vid` cookie when the request carries your Profile's proxy secret (`x-atribu-proxy-secret`) — a value that must never reach the browser. A rewrite has no way to attach it.

So the Next.js recipe is a real **Route Handler**: a few lines of server code that add the secret, forward the visitor's real IP, and relay Atribu's response — including its `Set-Cookie` — unchanged.

---

## Setup

<Steps>
<Step>

### Save your addresses in Atribu

Go to **Settings > Tracking > Domains > Same-origin collector** and enter:

- **Your site** — `https://yourstore.com`
- **Collector path on your site** — `https://yourstore.com/atb`

Click **Save**. Atribu shows a **proxy secret** once. Copy it now.

</Step>
<Step>

### Install the package

```bash
npm install @atribu/tracker
```

`@atribu/tracker/next` is a separate entry point inside the same package — a tiny, dependency-free proxy helper. It does not pull in the browser tracker runtime.

</Step>
<Step>

### Add the route

Create a catch-all Route Handler at the path you chose (`/atb` by default):

```ts title="app/atb/[...slug]/route.ts"
import { createAtribuProxy } from "@atribu/tracker/next";

export const { GET, POST } = createAtribuProxy({
  secret: process.env.ATRIBU_PROXY_SECRET!,
  path: "/atb",
});
```

`createAtribuProxy` serves the tracker script at `<path>/atribu-tracker.js` and forwards every `<path>/api/tracking/*` call to Atribu with the header the trust check requires.

</Step>
<Step>

### Set the secret

Add the secret from step 1 as an environment variable in your Vercel project (**Settings > Environment Variables**), or your `.env.local` for local testing:

```bash title=".env.local"
ATRIBU_PROXY_SECRET=atbps_...
```

<Callout type="warn" title="Server-only">
`ATRIBU_PROXY_SECRET` must **not** be prefixed `NEXT_PUBLIC_` — it is read only inside the Route Handler, which runs on the server. Prefixing it would ship it to every visitor's browser.
</Callout>

</Step>
<Step>

### Deploy, then check the installation

Deploy your app. Back in Atribu, click **Check installation**. It passes when:

- the path answers through your route and reaches Atribu with the right secret, and
- it answers from the same network as your site (Safari's condition for keeping the cookie) — automatic here, since the route lives on your site's own domain.

</Step>
<Step>

### Replace your tracking code

Copy the **Tracking code for this mode** shown in Atribu and replace your current Atribu snippet with it. It loads the tracker from `yourstore.com/atb/atribu-tracker.js`.

</Step>
</Steps>

<Callout type="info" title="Visitors already on your site keep their history">
The first time a returning visitor reaches the new collector, their current Atribu ID becomes the long-lived one. Nobody is reset.
</Callout>

---

## Do not use the Edge runtime

Do not add `export const runtime = "edge";` to the route. The proxy needs the visitor's real IP, which it reads from `x-forwarded-for` / `x-real-ip` the way Vercel's **Node.js** functions receive them — the default runtime for a Route Handler, and the one this recipe is written for. The Edge runtime sits behind a different boundary and is not what this recipe was measured against.

## Visitor location

On Vercel, the proxy also forwards the visitor's country, region and city from Vercel's own `x-vercel-ip-country`, `x-vercel-ip-country-region` and `x-vercel-ip-city` headers. Atribu needs them: the request reaches it from your function's region, so without them it would see your server's location, not your visitor's, and records the location as unknown instead. Nothing to configure. Off Vercel those headers are absent and no location is forwarded.

## Sharing the cookie across subdomains

By default the cookie belongs to the exact hostname the visitor is on. If your store spans `www.yourstore.com` and `shop.yourstore.com`, pass `cookieDomain`:

```ts title="app/atb/[...slug]/route.ts"
export const { GET, POST } = createAtribuProxy({
  secret: process.env.ATRIBU_PROXY_SECRET!,
  path: "/atb",
  cookieDomain: "yourstore.com",
});
```

---

## What the proxy sends, and what it does not

Same rules as the Cloudflare Worker template — this recipe implements the identical contract:

- It forwards only what the tracker needs: the event, the browser's user agent and language, the page address, the visitor's IP address, and the visitor's country, region and city as Vercel resolved them.
- It forwards **only** Atribu's own `atb_vid` cookie. Your app's other cookies (auth sessions, carts) never leave your server.
- The proxy secret stays server-side. It is never sent to browsers, and Atribu stores only a fingerprint of it.
- Nothing here is cached — not by Next.js's own fetch cache, not by any CDN in front of your deployment. A cached collect response, or a cached script carrying another visitor's id, would be a real bug, so every outbound call the route makes uses `cache: "no-store"` and every response it returns sets `Cache-Control: no-store` (the tracker script itself is `private, max-age=300` — short-lived and marked `Vary: Cookie` so it is never shared across visitors).

---

## Reference

`createAtribuProxy(config)` returns `{ GET, POST }` — the two functions your route file re-exports.

| Option | Required | Default | Meaning |
|---|---|---|---|
| `secret` | Yes | — | Your Profile's proxy secret, from an env var. |
| `path` | No | `/atb` | The path this route is mounted at. Must match the folder (`app/atb/[...slug]` → `/atb`) and the "Collector path on your site" you saved in Atribu. |
| `origin` | No | `https://www.atribu.app` | Atribu origin to proxy to. |
| `cookieDomain` | No | — | A parent domain to share `atb_vid` across subdomains. |
| `alias` | No | — | Your "harder-to-block tracker address" from **Settings > Tracking**, if you turned it on. The route then also serves the tracker at `<path>/<alias>.js` and accepts events at `<path>/<alias>`. |

The module has no dependency on `next` — it is written against the standard `Request`/`Response` objects a Route Handler already uses, so `next/server`'s `NextRequest`/`NextResponse` (which extend them) work too, and nothing framework-specific is required to call it in a test.

---

## Next steps

<Cards>
  <Card title="Same-Origin Collector" href="/docs/tracking/same-origin-collector">
    The Cloudflare Worker template, and why this exists
  </Card>
  <Card title="Framework Guides" href="/docs/tracking/framework-guides">
    Install the browser tracker itself in a Next.js app
  </Card>
  <Card title="How Tracking Works" href="/docs/tracking/how-tracking-works">
    Understand visitors, sessions, and cookies
  </Card>
</Cards>

---

# WordPress / WooCommerce Plugin

The Atribu plugin serves the tracker from a path on your own WordPress site (for example `yourstore.com/atb`) so Safari and iPhones keep recognizing returning visitors for up to **400 days** instead of **7** — the same [same-origin collector](/docs/tracking/same-origin-collector) mechanism as the Cloudflare Worker template, packaged as a WordPress plugin so no code editing is required. With WooCommerce active, it also sends `add_to_cart`, `checkout_started` and `purchase` events.

---

## Why this matters

Safari's Intelligent Tracking Prevention (ITP) deletes anything a script stores in the browser after 7 days — 24 hours if the visitor arrived from an ad click. A custom tracking subdomain does not fix this: Safari also caps a cookie set by a *server* outside your site's own network, and a CNAME to `collect.atribu.app` is exactly that. Routing through your own WordPress install is the fix — Atribu's response sets a secure, server-side cookie (`atb_vid`) that Safari leaves alone, because it looks exactly like any other cookie your own site sets.

| Setup | Blocks ad blockers | Survives Safari's 7-day limit |
|---|---|---|
| Default (`atribu.app`) | No | No |
| Custom tracking domain (CNAME) | Yes | **No** |
| WordPress plugin (this page) | Yes | **Yes** |

---

## Install

<Steps>
<Step>

### Get your tracking ID and proxy secret

In Atribu, go to **Settings > Tracking > Domains > Same-origin collector** and enter your site's address (`https://yourstore.com`) and the collector path (`https://yourstore.com/atb` — you can change the path later). Save. Atribu shows a **proxy secret** once — copy it now — and your **tracking ID** is on the same Tracking settings page.

</Step>
<Step>

### Install the plugin

Download the plugin from the [GitHub repository](https://github.com/AtribuCore/atribu/tree/main/integrations/wordpress/atribu) (or the WordPress.org listing, once published) and install it under **Plugins > Add New > Upload Plugin**, then activate it.

</Step>
<Step>

### Configure it

Go to **Settings > Atribu Tracking** in your WordPress admin. Paste in your tracking ID and proxy secret, confirm the collector path matches what you entered in Atribu, and click **Save Changes**.

</Step>
<Step>

### Test the connection

Click **Test connection** on the same page. It confirms this site's proxy reaches Atribu with the right secret — the same check Atribu's own dashboard "Check installation" button runs.

</Step>
<Step>

### Remove any existing Atribu snippet

If you previously pasted an Atribu `<script>` snippet into your theme, a header/footer plugin, or Google Tag Manager, remove it. This plugin loads the tracker itself once a tracking ID is saved — two copies would double-count events.

</Step>
</Steps>

<Callout type="info" title="Visitors already on your site keep their history">
The first time a returning visitor reaches the new collector, their current Atribu ID becomes the long-lived one. Nobody is reset.
</Callout>

---

## Behind Cloudflare (or another reverse proxy)?

By default, the plugin reads the visitor's IP address from the raw TCP connection WordPress itself received (`REMOTE_ADDR`) — the one value a visitor cannot fake, no matter what headers their browser sends. If your site sits behind Cloudflare (orange-cloud proxied) or another reverse proxy / load balancer, that raw connection is *Cloudflare's* address, not the visitor's.

Go to **Settings > Atribu Tracking > Visitor IP source** and switch it to match your setup:

- **Cloudflare (CF-Connecting-IP)** — only if the site is actually proxied through Cloudflare. Cloudflare's edge is the only thing that can set this header; if you select this on a site Cloudflare does *not* front, any visitor could fake their own IP by sending that header themselves.
- **Reverse proxy (last X-Forwarded-For hop)** — only with a trusted load balancer in front of WordPress that you know always overwrites this header rather than passing through whatever the visitor sent.

Leaving it on the default is always safe — the worst case is that reported visitor IPs are your CDN's own address rather than the real one, which only affects fraud/geo signals, never whether `atb_vid` gets set.

---

## WooCommerce events

With WooCommerce active and **Settings > Atribu Tracking > "Send add-to-cart, checkout and purchase events to Atribu"** checked (on by default):

| Event | Fires when | Carries |
|---|---|---|
| `add_to_cart` | A shopper adds a product to their cart (the standard AJAX "Add to cart" button) | Product ID, quantity |
| `checkout_started` | A shopper reaches the checkout page with items in their cart | Cart total, currency, item count |
| `purchase` | An order confirmation ("thank you") page renders, once per order | Order ID, order total, currency, and the customer's billing email/phone (sent through Atribu's `identify()` call, in the request body — never in a URL) |

All three fire through the same tracker script this plugin loads, so every event carries the shopper's live visitor ID and session — the sale is linked back to whichever ad, if any, brought them to your site, even if that happened days earlier.

`purchase` is deduplicated per order: reloading the thank-you page, or a customer revisiting an old order-confirmation link, never sends a second purchase event for the same order.

---

## What the proxy sends, and what it does not

- It forwards only what the tracker needs: the event, the browser's user agent and language, the page address, and the visitor's IP address (so location and fraud checks keep working).
- It forwards **only** Atribu's own `atb_vid` cookie. Your site's other cookies (logins, WooCommerce's cart/session cookies, nonces) never leave your server.
- The proxy secret is stored in your WordPress database and used only from PHP, server to server. It is never sent to the browser, and the settings page never displays a previously-saved value back to you.
- `atb_vid` holds a random visitor ID — no personal data.

---

## Troubleshooting

**"Test connection" says "Not trusted yet"** — double-check the tracking ID and proxy secret match exactly what Atribu shows under Settings > Tracking, and that you saved the settings form.

**The tracker script 404s** — a caching plugin or CDN rule may be caching or blocking the collector path. Exclude `/atb/*` (or your configured path) from caching and from any "minify/combine" rule.

**Changed the collector path and it stopped working** — some hosts cache WordPress's permalink/rewrite rules aggressively. Saving the settings page flushes them automatically; if it still 404s, visit **Settings > Permalinks** and click **Save Changes** there too.

---

## For developers: the proxy contract

This plugin is one of three delivery templates for the same contract: a Cloudflare Worker, a Next.js rewrite, and this plugin. See [Same-Origin Collector](/docs/tracking/same-origin-collector) (the "For developers" section near the bottom) for the full request/response contract, and the plugin's own source and README at [`integrations/wordpress/atribu/`](https://github.com/AtribuCore/atribu/tree/main/integrations/wordpress/atribu) in the Atribu repository.

---

## Next steps

<Cards>
  <Card title="Same-Origin Collector" href="/docs/tracking/same-origin-collector">
    The underlying mechanism, and the Cloudflare Worker template
  </Card>
  <Card title="How Tracking Works" href="/docs/tracking/how-tracking-works">
    Understand visitors, sessions, and cookies
  </Card>
</Cards>

---

# UTM Parameters

UTM parameters tell Atribu where your traffic comes from. They are small tags added to the end of your URLs that identify the traffic source, medium, and campaign.

---

## What are UTM parameters?

When you share a link in an ad, email, or social post, you can add UTM parameters to the URL so Atribu knows exactly where the visitor came from. Here is an example:

```text title="URL with UTM parameters"
https://yoursite.com/landing-page?utm_source=facebook&utm_medium=paid&utm_campaign=spring_sale&utm_content=video_ad_1
```

Everything after the `?` is a set of key-value pairs that Atribu reads and stores with the visitor's session. This data powers your Traffic Sources breakdown and campaign-level attribution.

---

## UTM parameters Atribu reads

| Parameter | What it tells Atribu | Example values |
|-----------|---------------------|----------------|
| `utm_source` | Where the traffic comes from | `facebook`, `google`, `newsletter`, `linkedin` |
| `utm_medium` | How they got there | `paid`, `cpc`, `email`, `organic`, `social` |
| `utm_campaign` | Which campaign sent them | `spring_sale`, `black_friday`, `webinar_march` |
| `utm_content` | Which ad or content variation | `video_ad_1`, `banner_blue`, `cta_bottom` |
| `utm_term` | Which keyword (for search ads) | `marketing_tools`, `crm_software` |

<Callout type="info" title="Only utm_source and utm_medium are required">
At minimum, always include `utm_source` and `utm_medium`. The other parameters are optional but highly recommended for detailed reporting.
</Callout>

---

## Click IDs (automatic, no setup needed)

Ad platforms automatically append a click ID to your URLs when someone clicks an ad. These are separate from UTM parameters -- you do not need to set them up.

| Click ID | Platform | What it does |
|----------|----------|-------------|
| `fbclid` | Meta (Facebook / Instagram) | Automatically added by Meta to every ad click |
| `gclid` | Google Ads | Automatically added by Google to every ad click |
| `msclkid` | Microsoft / Bing Ads | Automatically added by Microsoft to every ad click |
| `ttclid` | TikTok Ads | Automatically added by TikTok to every ad click |

Atribu detects these click IDs and uses them for two things:

1. **Channel classification** -- A visit with `fbclid` is classified as Paid Social, even if UTM parameters are missing
2. **Ad-level attribution** -- Click IDs help trace a specific click back to the exact ad that generated it

<Callout type="info" title="Click IDs take priority">
If a visit has both a click ID and UTM parameters, the click ID takes priority for channel classification. This prevents misclassification when UTM tags are missing or incorrect. For example, a visit with `gclid` is always classified as **Paid Search**, regardless of what `utm_medium` says.
</Callout>

---

## How UTM parameters feed into channels

Atribu automatically classifies each visit into a marketing channel based on UTMs, referrer, and click IDs:

| What Atribu sees | Classified as |
|-----------------|---------------|
| `utm_medium=paid` or `utm_medium=cpc` with `utm_source=facebook` | **Paid Social** |
| `utm_medium=paid` or `utm_medium=cpc` with `utm_source=google` | **Paid Search** |
| `fbclid` or `ttclid` in URL (no social referrer) | **Paid Social** |
| `gclid` or `msclkid` in URL | **Paid Search** |
| `utm_medium=email` | **Email** |
| `utm_medium=affiliate` | **Affiliates** |
| `utm_medium=display` | **Display** |
| Referrer from google.com, bing.com (no paid indicators) | **Organic Search** |
| Referrer from facebook.com, instagram.com (no paid indicators) | **Organic Social** |
| Other referrer | **Referral** |
| No referrer, no UTMs, no click IDs | **Direct** |

---

## Best practices

### Always pair utm_source and utm_medium

These two parameters work together. `utm_source` identifies the platform, `utm_medium` identifies the type of traffic:

```text title="Good: source + medium together"
?utm_source=facebook&utm_medium=paid
?utm_source=google&utm_medium=cpc
?utm_source=mailchimp&utm_medium=email
```

### Use consistent, lowercase naming

Pick a convention and stick with it across all your campaigns:

```text title="Consistent naming"
utm_source=facebook    (not Facebook, fb, or FB)
utm_medium=paid        (not Paid, cpc, or PPC)
utm_campaign=spring_sale  (not Spring Sale or SpringSale)
```

Atribu treats `Facebook` and `facebook` as different sources, so inconsistent casing leads to fragmented reports.

### Do not use UTMs on internal links

UTM parameters should only be used on links from **external** sources pointing to your site. Never add UTMs to links within your own website (like navigation menus or internal banners) -- this overwrites the original traffic source and breaks attribution.

```text title="External link (correct)"
Email newsletter link: https://yoursite.com/sale?utm_source=newsletter&utm_medium=email

Internal link (never do this):
https://yoursite.com/pricing?utm_source=homepage&utm_medium=banner
```

### Include utm_content for ad variations

When running multiple ad variations in the same campaign, use `utm_content` to distinguish them:

```text title="Distinguishing ad variations"
?utm_source=facebook&utm_medium=paid&utm_campaign=spring_sale&utm_content=video_ad
?utm_source=facebook&utm_medium=paid&utm_campaign=spring_sale&utm_content=carousel_ad
?utm_source=facebook&utm_medium=paid&utm_campaign=spring_sale&utm_content=static_image
```

This lets you see which creative variation drives the most conversions.

---

## Meta Ads and UTM parameters

When you connect your Meta Ads account to Atribu, campaign and ad data is synced automatically. Meta also appends `fbclid` to every ad click URL. For the best results, add UTM parameters to your Meta ad URLs:

```text title="Recommended Meta ad URL parameters"
?utm_source=facebook&utm_medium=paid&utm_campaign={{campaign.name}}&utm_content={{ad.name}}
```

Meta's dynamic URL parameters (`{{campaign.name}}`, `{{ad.name}}`) are automatically replaced with the actual campaign and ad names when someone clicks.

---

## Next steps

<Cards>
  <Card title="Channel Classification" href="/docs/concepts/channels">
    Full reference of how channels are classified
  </Card>
  <Card title="How Tracking Works" href="/docs/tracking/how-tracking-works">
    Understand visitors, sessions, and cookies
  </Card>
  <Card title="Traffic Sources" href="/docs/features/traffic-sources">
    See how UTM data appears in your dashboard breakdown boards
  </Card>
</Cards>

---

# Meta (Facebook & Instagram)

The Meta integration connects your Facebook and Instagram ad accounts to Atribu. It syncs your full campaign hierarchy with daily performance data and enables two-way data flow through the Meta Conversions API (CAPI).

---

## What you get

- Full campaign structure: campaigns, ad sets, and individual ads
- Daily spend, impressions, clicks, reach, and frequency for every ad
- Video creative source URLs for previewing ads directly in Atribu
- Automatic traffic classification -- visits from Meta ads are labeled **Paid Social**
- Conversion export via CAPI so Meta can optimize your ad delivery

---

## Connecting Meta

<Steps>
<Step>

## Open Integrations

Go to **Settings > Integrations** in your Atribu workspace.

</Step>
<Step>

## Start the connection

Click **Connect** next to the Meta (Facebook & Instagram) card. You will be redirected to Facebook.

</Step>
<Step>

## Authorize on Facebook

Sign in with the Facebook account that manages your ad accounts. Grant Atribu the requested permissions:

- **Ads Read** -- lets Atribu pull campaign data and performance metrics
- **Ads Management** -- lets Atribu send conversion data back to Meta (CAPI)
- **Pages Read Engagement** -- lets Atribu load video creatives from your Page

<Callout type="warn" title="Grant all permissions">
If you skip a permission, some features will not work. Video previews require Pages Read Engagement, and CAPI requires Ads Management.
</Callout>

</Step>
<Step>

## Select ad accounts

Choose which ad accounts you want to sync. Atribu will begin importing data immediately.

</Step>
</Steps>

---

## What syncs

| Data | Description |
|------|-------------|
| **Campaigns** | Campaign name, status, objective, budget |
| **Ad Sets** | Targeting, placement, schedule, bid strategy |
| **Ads** | Creative, copy, video IDs, thumbnail URLs |
| **Daily Spend** | Spend amount, impressions, clicks, reach, frequency per day |
| **Video Sources** | Playable video URLs for previewing creatives in Atribu |

Performance metrics like CPM, CPC, and CTR are calculated in real time from the raw data (spend, impressions, clicks) rather than stored as static values. This means they always reflect the most up-to-date numbers, even after currency normalization.

---

## Sync frequency

After connecting, Atribu runs an initial sync to import your historical data. After that, data syncs periodically throughout the day. You can also trigger a manual sync from **Settings > Integrations** at any time.

New campaigns and ads typically appear within a few hours of being created in Meta Ads Manager.

---

## Meta Conversions API (CAPI)

The Meta Conversions API sends your conversion data (leads, purchases, appointments) back to Meta. This helps Meta's algorithm understand which ad clicks lead to real business outcomes, so it can optimize your ad delivery and find more people like your best customers.

In simple terms: Atribu tells Meta "this person clicked your ad and then became a paying customer," so Meta can show your ads to similar people.

<Callout type="info" title="Why CAPI matters">
As browser privacy restrictions grow (cookie blocking, iOS tracking limits), server-side conversion data through CAPI becomes increasingly important for accurate ad optimization. Without it, Meta may not know that many of your conversions came from their ads.
</Callout>

---

## Click ID tracking

Meta automatically adds a `fbclid` parameter to the URL when someone clicks your ad. For example:

```txt title="Example URL with fbclid"
https://yoursite.com/landing-page?fbclid=AbC123xYz...
```

Atribu detects this click ID and uses it to:

1. Classify the visit as **Paid Social** in your traffic breakdown
2. Link the visitor to the specific Meta campaign, ad set, and ad they clicked
3. Track whether that visitor later converts (fills a form, makes a payment, etc.)

You do not need to set up UTM parameters manually -- `fbclid` handling is automatic.

---

## Re-authorization

<Callout type="warn" title="Re-authorization may be needed">
If Atribu adds new features that require additional Facebook permissions, you may need to re-authorize the connection. Go to **Settings > Integrations** and click **Re-authorize** next to Meta. This will show the Facebook permissions dialog again so you can grant the new permissions.
</Callout>

---

## Troubleshooting

### Video previews are not loading

Ad videos are owned by your Facebook Page, not your personal account. Atribu needs the **Pages Read Engagement** permission to access them. If you skipped this permission during setup:

1. Go to **Settings > Integrations**
2. Click **Re-authorize** next to Meta
3. Make sure all permissions are granted

### I do not see all my ad accounts

Your Facebook user account needs access to the ad accounts through Business Manager. Check that:

- You are an admin or advertiser on the ad account
- The ad account is part of a Business Manager you have access to
- You selected the correct ad accounts during the connection step

### Data seems outdated

Meta rate-limits API requests, so there may be a delay of a few hours for the latest data. You can trigger a manual sync from **Settings > Integrations** to refresh immediately.

---

## Related

<Cards>
  <Card title="Install the Tracker" href="/docs/getting-started/install-tracker">
    Required for connecting ad clicks to website visits
  </Card>
  <Card title="UTM Parameters" href="/docs/tracking/utm-parameters">
    Tag your Meta ad URLs for detailed attribution
  </Card>
  <Card title="Ads Performance" href="/docs/features/ads-performance">
    See your Meta campaigns ranked by ROAS and other metrics
  </Card>
  <Card title="Conversion Sync" href="/docs/features/conversion-sync">
    Send conversions back to Meta CAPI for better ad optimization
  </Card>
</Cards>

---

# Google Ads

The Google Ads integration connects your search, display, and video campaigns to Atribu. It syncs your full campaign hierarchy with daily performance data and lets you export conversion events back to Google for bid optimization.

---

## What you get

- Full campaign structure: campaigns, ad groups, and individual ads
- Daily spend, impressions, and clicks for every ad
- Automatic traffic classification -- visits from Google Ads are labeled **Paid Search**
- Conversion export so Google can optimize your bids toward real business outcomes

---

## Connecting Google Ads

<Steps>
<Step>

## Open Integrations

Go to **Settings > Integrations** in your Atribu workspace.

</Step>
<Step>

## Start the connection

Click **Connect** next to the Google Ads card. You will be redirected to Google.

</Step>
<Step>

## Sign in with Google

Sign in with the Google account that has access to your Google Ads accounts. Grant Atribu permission to read and manage your ad data.

<Callout type="info" title="Offline access">
Atribu requests offline access so it can sync data in the background without requiring you to be logged in.
</Callout>

</Step>
<Step>

## Select your customer account

Choose which Google Ads customer account (also called a CID) you want to sync. If you use a Manager Account (MCC), you will see all linked accounts.

</Step>
</Steps>

---

## What syncs

| Data | Description |
|------|-------------|
| **Campaigns** | Campaign name, status, type (Search, Display, Video, Performance Max) |
| **Ad Groups** | Ad group name, targeting, bid strategy |
| **Ads** | Ad copy, headlines, descriptions, final URLs |
| **Daily Spend** | Spend amount, impressions, clicks per day |

Performance metrics like CPC and CTR are calculated in real time from the raw data rather than stored as static numbers.

---

## Google Ads conversion export

Atribu can send conversion events back to Google Ads. This tells Google which ad clicks led to actual business outcomes (leads, appointments, purchases), so its Smart Bidding algorithms can optimize toward the results that matter to your business.

For example, if most of your paying customers come from a specific keyword group, Google will learn to bid more aggressively on those keywords.

---

## Click ID tracking

Google automatically adds a `gclid` parameter to the URL when someone clicks your ad:

```txt title="Example URL with gclid"
https://yoursite.com/landing-page?gclid=Cj0KCQj...
```

Atribu detects this click ID and uses it to:

1. Classify the visit as **Paid Search** in your traffic breakdown
2. Link the visitor to the specific Google Ads campaign and ad group
3. Track whether that visitor later converts

You do not need to set up UTM parameters manually -- `gclid` handling is automatic.

---

## Troubleshooting

### I do not see my ad accounts

If your Google Ads accounts do not appear during setup:

- Make sure the Google account you signed in with has **Standard** or **Admin** access to the Google Ads account
- If you use a Manager Account (MCC), check that the account is linked and active
- Try signing out of Google and signing back in with the correct account

### Data is not syncing

If your campaigns appear but spend data is missing or outdated:

1. Go to **Settings > Integrations** and check the connection status
2. If the status shows an error, try disconnecting and reconnecting
3. Google Ads data is typically available within a few hours of each day ending

---

## Related

<Cards>
  <Card title="Install the Tracker" href="/docs/getting-started/install-tracker">
    Required for connecting ad clicks to website visits
  </Card>
  <Card title="Ads Performance" href="/docs/features/ads-performance">
    See your Google Ads campaigns ranked by ROAS
  </Card>
  <Card title="Channel Classification" href="/docs/concepts/channels">
    How Google Ads traffic is classified as Paid Search
  </Card>
  <Card title="Conversion Sync" href="/docs/features/conversion-sync">
    Send conversions back to Google Ads for better optimization
  </Card>
</Cards>

---

# Stripe

The Stripe integration tracks every payment your business receives as a conversion event. Atribu attributes that revenue back to the ad campaign, ad set, and specific ad that originally brought the customer to your site.

---

## What you get

- Every Stripe payment recorded as a `payment_received` conversion
- Revenue attributed to specific ad campaigns for accurate ROAS
- Customer identity matching -- Atribu links payments to earlier ad clicks

---

## Connecting Stripe

<Steps>
<Step>

## Open Integrations

Go to **Settings > Integrations** in your Atribu workspace.

</Step>
<Step>

## Start the connection

Click **Connect** next to the Stripe card. You will be redirected to Stripe.

</Step>
<Step>

## Authorize on Stripe

Sign in with your Stripe account and authorize Atribu. This sets up a secure webhook so Stripe notifies Atribu every time a payment occurs.

</Step>
<Step>

## Verify the connection

Back in Atribu, the Stripe card should show **Connected**. Payments will start flowing in automatically.

</Step>
</Steps>

---

## How it works

When a customer makes a payment through Stripe, the following happens:

1. **Stripe sends a webhook** to Atribu with the payment details (amount, currency, customer email, billing info)
2. **Atribu creates a conversion** -- a `payment_received` event is recorded with the exact amount
3. **The identity graph links the payment** -- Atribu matches the customer's email from the payment to any previous visits on your site
4. **Attribution is computed** -- Atribu traces back to the original ad click and assigns revenue credit to the campaign

Atribu processes three types of Stripe events to ensure no payment is missed:

| Event | Purpose |
|-------|---------|
| `charge.succeeded` | Primary payment event -- most charges arrive this way |
| `checkout.session.completed` | Richer data from Stripe Checkout and Payment Links |
| `payment_intent.succeeded` | Fallback for cases where the charge is not in the payload |

<Callout type="info" title="How payment attribution works">
Here is a typical flow: a customer clicks your Meta ad, visits your landing page, and fills out a contact form. Two weeks later, they pay via Stripe. Atribu matches the email from the Stripe payment to the email from the form submission, then traces that visitor back to the original Meta ad click. The revenue is attributed to that specific ad.
</Callout>

---

## What data Atribu receives

| Data | Description |
|------|-------------|
| **Payment amount** | The amount charged, in the original currency |
| **Currency** | USD, EUR, MXN, etc. -- Atribu normalizes to your reporting currency |
| **Customer email** | From receipt email or billing details |
| **Customer name** | From billing details, if provided |
| **Customer phone** | From billing details, if provided |
| **Stripe Customer ID** | Used for identity matching across multiple payments |

---

## Revenue and ROAS

<Callout type="warn" title="Stripe payments are the gold standard for ROAS">
Stripe payments are classified as **cash** revenue. This is the **only** type of revenue used for ROAS (Return on Ad Spend) calculations. Pipeline values from CRMs like GoHighLevel are tracked separately and never mixed into ROAS. This ensures your ROAS reflects actual money received, not projections.
</Callout>

---

## Stripe dashboard extension

Atribu has a Stripe App that runs directly inside your Stripe dashboard. It shows you which ad campaigns drove each customer and their attributed revenue -- without leaving Stripe. Ask your account manager about enabling it.

---

## Troubleshooting

### Payments are not appearing

- Check that the connection status shows **Connected** in **Settings > Integrations**
- Stripe webhooks may take a few seconds to arrive. Refresh the page after a minute.
- If the issue persists, disconnect and reconnect the Stripe integration

### A payment shows as unattributed

Attribution requires the customer to be identified before paying. This usually happens when:

- The customer filled out a form on your site (email or phone captured)
- The customer's email on Stripe matches an email already in Atribu

If a customer pays without ever visiting your tracked site or submitting their email beforehand, the payment will be recorded but cannot be attributed to an ad campaign.

---

## Related

<Cards>
  <Card title="Revenue Types" href="/docs/concepts/revenue-types">
    Why only cash payments count for ROAS
  </Card>
  <Card title="Identity Resolution" href="/docs/concepts/identity-resolution">
    How payments are matched to ad clicks via the identity graph
  </Card>
  <Card title="Conversion Sync" href="/docs/features/conversion-sync">
    Send payment events back to ad platforms for revenue optimization
  </Card>
</Cards>

---

# GoHighLevel (GHL)

The GoHighLevel integration brings your CRM data into Atribu. It syncs contacts, opportunities (deals), and calendar appointments so you can track every step of your sales funnel -- from ad click to closed deal.

---

## What you get

- Contacts synced with Atribu's identity graph (name, email, phone)
- Opportunities with deal values, pipeline stages, and UTM attribution data
- Calendar appointments merged into Atribu's Calendar view
- Full-funnel tracking from first ad click through to closed deal

---

## Connecting GoHighLevel

<Steps>
<Step>

## Open Integrations

Go to **Settings > Integrations** in your Atribu workspace.

</Step>
<Step>

## Start the connection

Click **Connect** next to the GoHighLevel card. You will be redirected to the GHL Marketplace.

</Step>
<Step>

## Select your location

Choose the GHL location (sub-account) you want to connect. Each location connects to one Atribu profile.

</Step>
<Step>

## Authorize permissions

Grant Atribu the requested permissions:

- **Contacts** -- read contact data (name, email, phone)
- **Opportunities** -- read deal data (stage, value, attribution)
- **Locations** -- identify your sub-account
- **Calendars & Events** -- read appointment data

</Step>
<Step>

## Verify the connection

Back in Atribu, the GHL card should show **Connected**. Atribu will begin importing your contacts and opportunities.

</Step>
</Steps>

---

## What syncs

### Contacts

Every GHL contact is linked to Atribu's identity graph using email and phone number. This creates a unified customer profile that connects the contact to their website visits, ad clicks, and payments.

| Data | Description |
|------|-------------|
| **Name** | First and last name |
| **Email** | Primary email address |
| **Phone** | Primary phone number |
| **Source** | Where the contact came from (form, ad, referral) |

### Opportunities (deals)

GHL opportunities carry rich attribution data that Atribu reads to connect deals back to ad campaigns.

| Data | Description |
|------|-------------|
| **Pipeline stage** | Current stage (e.g., New Lead, Qualified, Closed Won) |
| **Deal value** | Monetary value of the opportunity |
| **Contact** | Linked contact with identity data |
| **UTM data** | Campaign, source, medium, and click IDs from the lead's origin |
| **Stage history** | Timeline of stage changes |

### Calendar events

GHL appointments are synced and displayed in Atribu's Calendar view, merged with tracked events from your website.

| Data | Description |
|------|-------------|
| **Appointment time** | Start and end time |
| **Contact** | Linked contact information |
| **Assigned user** | Team member responsible |
| **Calendar name** | Which calendar the appointment belongs to |
| **Status** | Confirmed, cancelled, no-show, etc. |

---

## Pipeline stage mapping

GHL organizes deals into pipelines with stages. For example:

```txt title="Example sales pipeline"
New Lead  -->  Qualified  -->  Proposal Sent  -->  Closed Won
```

Atribu maps each stage to a conversion type so it can track your funnel:

| Stage keyword | Conversion type |
|---------------|----------------|
| Contains "lead" or is the first stage | `lead_created` |
| Contains "appointment" or "booked" | `appointment_booked` |
| Contains "won" or "closed" | `closed_won` |

You can customize this mapping in **Settings > Outcomes** to match your specific pipeline stages.

---

## Pipeline values vs real revenue

<Callout type="warn" title="Pipeline values are NOT used for ROAS">
GHL deal values represent what you **expect** to earn -- not what you have actually collected. A $5,000 deal in your pipeline might close for $3,000, get renegotiated, or fall through entirely.

Atribu tracks pipeline values separately as **pipeline revenue** and displays them in their own section of the dashboard. They are **never** included in ROAS calculations. Only confirmed payments from Stripe or MercadoPago count for ROAS.
</Callout>

---

## Calendar merge

GHL appointments appear in Atribu's Calendar view alongside events tracked from your website. Atribu merges data from both sources:

- **GHL data**: real-time appointment status, calendar name, assigned team member
- **Atribu data**: attribution information (which ad campaign brought this customer)

When both sources have data for the same appointment, they are merged into a single entry with the best of both worlds.

---

## UTM attribution from GHL

When a lead comes from a Meta or Google ad, GHL captures the campaign information from the click. Atribu reads this data to attribute the lead back to the correct campaign, ad set, and ad.

This means that even if a lead fills out a form directly on Facebook (using a Meta Lead Form) and goes straight into GHL without visiting your website, Atribu can still identify which ad campaign generated the lead.

<Callout type="info" title="Synthetic touches for off-site leads">
When a lead enters GHL from a Meta lead form (not from your website), there is no website visit to track. Atribu automatically creates **synthetic touches** -- virtual touchpoints that represent the ad interaction. This ensures these leads are included in your attribution reports, even without a website session.

Learn more in [Synthetic Touches](/docs/concepts/synthetic-touches).
</Callout>

---

## Troubleshooting

### Contacts are missing names

Some GHL contacts are created through opportunities rather than the contacts list. In these cases, the name may not be available during the initial sync. Atribu will backfill names as more data becomes available from subsequent syncs.

### Opportunities are not syncing

- Check that the connection status shows **Connected** in **Settings > Integrations**
- Make sure the GHL location has at least one pipeline with opportunities
- If you recently added a new pipeline, trigger a manual sync

### Appointments are not showing in Calendar

Calendar sync requires the **Calendars** and **Calendar Events** permissions. If these were not granted during setup:

1. Go to **Settings > Integrations**
2. Disconnect GoHighLevel
3. Reconnect and make sure all permissions are granted

---

## Related

<Cards>
  <Card title="Revenue Types" href="/docs/concepts/revenue-types">
    Why pipeline values are tracked separately from cash revenue
  </Card>
  <Card title="Calendar" href="/docs/features/calendar">
    See GHL appointments with attribution data on a visual timeline
  </Card>
  <Card title="Conversion Goals" href="/docs/settings/goals">
    Customize how pipeline stages map to conversion types
  </Card>
</Cards>

---

# MercadoPago

The MercadoPago integration tracks payments from Latin American customers. If your business accepts payments through MercadoPago, Atribu records every transaction and attributes revenue to the ad campaigns that brought those customers.

---

## What you get

- Every MercadoPago payment recorded as a `payment_received` conversion
- Revenue attributed to specific ad campaigns for accurate ROAS
- Multi-currency support -- payments in MXN, BRL, ARS, COP, and other currencies are normalized to your reporting currency
- Customer identity matching via email addresses

---

## Connecting MercadoPago

<Steps>
<Step>

## Open Integrations

Go to **Settings > Integrations** in your Atribu workspace.

</Step>
<Step>

## Start the connection

Click **Connect** next to the MercadoPago card. You will be redirected to MercadoPago.

</Step>
<Step>

## Authorize on MercadoPago

Sign in with your MercadoPago account and authorize Atribu to receive payment notifications.

</Step>
<Step>

## Verify the connection

Back in Atribu, the MercadoPago card should show **Connected**. Payments will start flowing in automatically via webhooks.

</Step>
</Steps>

---

## How it works

The MercadoPago integration works the same way as Stripe:

1. **MercadoPago sends a webhook** to Atribu when a payment is completed
2. **Atribu creates a conversion** -- a `payment_received` event is recorded with the payment amount and currency
3. **The identity graph links the payment** -- Atribu matches the payer's email to any previous visits on your site
4. **Attribution is computed** -- Atribu traces back to the original ad click and assigns revenue credit

---

## What data Atribu receives

| Data | Description |
|------|-------------|
| **Payment amount** | The amount charged, in the original currency |
| **Currency** | MXN, BRL, ARS, COP, CLP, PEN, UYU, USD, etc. |
| **Payer email** | Used for identity matching |

---

## Currency normalization

<Callout type="info" title="Automatic currency conversion">
MercadoPago payments often arrive in local currencies (Mexican Pesos, Brazilian Reais, Argentine Pesos, etc.). Atribu automatically normalizes these amounts to your profile's reporting currency so all your revenue numbers, ROAS, and dashboards display in a single consistent currency.
</Callout>

---

## Revenue and ROAS

Just like Stripe, MercadoPago payments are classified as **cash** revenue. This is the only type of revenue used for ROAS calculations. Your ROAS reflects actual money received from customers, not projections or estimates.

---

## Troubleshooting

### Payments are not appearing

- Check that the connection status shows **Connected** in **Settings > Integrations**
- MercadoPago webhooks may take a few seconds to a minute to arrive
- If the issue persists, disconnect and reconnect the integration

### Payments show as unattributed

Attribution requires the payer's email to match a known visitor. If the customer paid without ever visiting your tracked site or submitting their email through a form, the payment will be recorded but cannot be attributed to an ad campaign.

### Currency looks wrong on the dashboard

Make sure your profile's reporting currency is set correctly in **Settings > Profile**. Atribu converts all incoming payments to this currency using current exchange rates.

---

## Related

<Cards>
  <Card title="Revenue Types" href="/docs/concepts/revenue-types">
    Why only cash payments count for ROAS
  </Card>
  <Card title="Identity Resolution" href="/docs/concepts/identity-resolution">
    How payments are matched to ad clicks via the identity graph
  </Card>
  <Card title="Install the Tracker" href="/docs/getting-started/install-tracker">
    Required for connecting ad clicks to website visits
  </Card>
</Cards>

---

# Fintoc (bank transfers)

In Chile, *transferencia* is often the largest share of a service business's revenue -- and until now Atribu had no way to see it. The Fintoc integration links **your own** bank account and records every incoming transfer as a `payment_received` conversion.

A bank transfer is also the one offline payment that arrives with an identifier already attached: the receiving bank records the sender's name and tax id (RUT). That is what lets a transfer be matched to the person who typed their RUT into a WhatsApp chat, or who is already in your patient file -- without you reconciling anything by hand.

---

## What Atribu reads, and what it does not

<Callout type="info" title="Your account, incoming movements only">
You link **your own** business bank account. Atribu reads the movements that account **receives** -- amount, date, and the sender name and RUT your bank already prints on your statement. It never sees a customer's bank credentials, a customer's account, or their balance, and it cannot move money.
</Callout>

You type your bank credentials into **Fintoc's** widget, never into Atribu. Atribu receives a link token that can read movements on that one account and nothing else.

---

## What you get

- Every received transfer recorded as a `payment_received` conversion, in the account's own currency
- The sender's RUT normalised into the same identifier a WhatsApp chat or a customer import produces -- so one person is one customer, not three
- Reversed transfers recorded as refunds, so the pair nets to zero instead of overstating revenue
- Transfers that match nobody still counted as **unattributed cash** -- never dropped, never silently zero

---

## Connecting a bank account

<Steps>
<Step>

## Open Integrations

Go to **Settings > Integrations** in your Atribu workspace, or the **Payments** step during onboarding.

</Step>
<Step>

## Start the link

Click **Connect** next to the Fintoc card. Fintoc's widget opens in place -- there is no redirect away from Atribu.

</Step>
<Step>

## Choose your bank and sign in

Pick your institution and sign in with the account holder's credentials. This happens inside Fintoc; Atribu never receives them.

</Step>
<Step>

## Verify the connection

The Fintoc card shows **Connected** with the institution and account. The first sync pulls recent movements; after that the nightly sync keeps it current.

</Step>
</Steps>

---

## How it works

1. **The nightly sync lists movements** on the linked account since the last run, re-reading one day of overlap
2. **Incoming transfers become conversions** -- a `payment_received` event with the amount, currency and the movement's own timestamp
3. **The RUT resolves the customer** -- the sender's tax id is normalised and matched against the identity graph
4. **Attribution is computed** -- if that customer has a click history, the transfer's revenue is credited to the campaign that brought them

---

## What data Atribu receives

| Data | Description |
|------|-------------|
| **Amount and currency** | The movement's amount, in the account's currency |
| **Sender name** | The account holder name your bank recorded |
| **Sender RUT** | The sender's tax id -- the deterministic identity key |
| **Date, description, comment** | The movement's own timestamp and the free-text fields the bank provides |

---

## Which movements are recorded, and which are not

| Movement | What Atribu does |
|---|---|
| Incoming transfer, `confirmed` | Recorded as `payment_received` |
| Incoming transfer, `reversed` | Recorded as a refund against the original transfer |
| Incoming transfer, `duplicated` | **Skipped** -- the bank is telling us it reported the same transfer twice |
| Incoming transfer, `processing` | Skipped for now; recorded once the bank confirms it |
| Outgoing transfer | Skipped -- money you paid out is not revenue |
| Cheques and other movement types | Skipped |

A reversal is only recorded when Atribu already recorded the transfer it reverses. A refund against a payment that was never counted would push revenue below zero rather than back to it.

---

## Revenue and ROAS

Transfers are classified as **cash** revenue -- the only type used for ROAS. See [Revenue Types](/docs/concepts/revenue-types).

---

## Troubleshooting

### Transfers are not appearing

- Check the connection status in **Settings > Integrations**. A bank that requires re-authentication shows an error; re-run the link to fix it.
- The sync runs nightly. Use **Sync now** on the card to pull immediately.
- Only **incoming** transfers are recorded. A payment you made shows as an outgoing movement and is skipped by design.

### A transfer shows as unattributed

The sender's RUT did not match any customer Atribu knows. That is still recorded as cash -- it is just not credited to a campaign. Asking for the RUT in the conversation (or importing your customer records) is what closes the gap.

### The amount looks wrong by a factor of 100

Fintoc reports amounts in the smallest currency unit. Chilean pesos have no decimals, so the amount is used as-is; a two-decimal currency is divided by 100. If a figure still looks wrong, check the profile's reporting currency in **Settings > Profile**.

---

## Related

<Cards>
  <Card title="MercadoPago" href="/docs/integrations/mercadopago">
    The other Latin American payment source, matched by email and RUT
  </Card>
  <Card title="Revenue Types" href="/docs/concepts/revenue-types">
    Why only cash payments count for ROAS
  </Card>
  <Card title="Identity Resolution" href="/docs/concepts/identity-resolution">
    How a RUT on a transfer becomes the same customer as a RUT in a chat
  </Card>
</Cards>

---

# Webpay (Transbank)

Webpay Plus is Chile's dominant card gateway. Unlike Stripe and MercadoPago it has **no `metadata` field and no webhook**, so Atribu cannot read your payments on its own — and without a passthrough every Webpay sale arrives with no ad signal at all.

Two lines of code fix that.

---

## What you get

- Every authorized Webpay transaction recorded as a `payment_received` conversion in CLP
- Revenue attributed to the exact ad click, with **no email or phone match required**
- Idempotent by construction — a retried commit, and a later upload of your monthly webpay.cl report, land on the same conversion instead of doubling your revenue

---

## How it works

Transbank echoes exactly two merchant-supplied values back to you when the payment completes: `buy_order` (max 26 characters) and `session_id` (max 61). `session_id` is the carrier — Transbank documents it as "uso interno de comercio", it has no uniqueness requirement to collide with, and it comes back on `Transaction.commit()`, on `Transaction.status()`, and as `TBK_ID_SESION` on the abort and timeout returns.

The full `atb1.` attribution token used for Stripe and MercadoPago is 80–904 characters, so it cannot ride either field. `getAttributionRef()` packs the visitor's identity into 48 characters instead, and Atribu recovers the campaign, ad set, ad and click IDs from that visitor's own tracked session.

<Callout type="info">
  The reference is base64url — letters, digits, `-` and `_` — all of which are inside the character set Transbank documents as legal for `buy_order`, so it is safe in either field.
</Callout>

---

## Step 1 — carry the reference into checkout

Where your page starts the payment, read the reference and send it as `session_id`:

```html
<script>
  // 48 characters. Always keep a fallback: getAttributionRef() returns ""
  // when the visitor's identity cannot be packed, and Webpay needs a value.
  const atribuRef =
    window.atribuTracker.getAttributionRef() || ("sess-" + orderId);
</script>
```

Then create the transaction with it, using Transbank's SDK or a raw request:

```typescript
import { WebpayPlus } from "transbank-sdk";

const response = await new WebpayPlus.Transaction().create(
  orderId,     // buy_order — yours, unchanged
  atribuRef,   // session_id — the attribution carrier
  amount,
  `${origin}/checkout/webpay-return`
);
// → redirect the browser to response.url with token_ws
```

Your `buy_order` stays entirely yours. If you cannot use `session_id`, pass `26` to `getAttributionRef(26)` and put the result in `buy_order` instead — it fits exactly, but then it consumes the whole field.

---

## Step 2 — report the commit

Your server is what calls `Transaction.commit()`, so your server is what tells Atribu the sale happened. Forward the response **verbatim** — Atribu does the field mapping:

```typescript
import { Atribu } from "@atribu/node";

const atribu = new Atribu({ apiKey: process.env.ATRIBU_API_KEY });

const commit = await new WebpayPlus.Transaction().commit(token_ws);

const result = await atribu.payments.webpay({
  commit,                                   // exactly as Transbank returned it
  customer: { email: buyerEmail },          // optional, if you have it
});

// result.attribution_carrier === "session_id" → the passthrough is wired
```

…or with plain HTTP:

```bash
curl -X POST https://api.atribu.app/api/v1/payments/webpay \
  -H "Authorization: Bearer $ATRIBU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"commit": {"vci":"TSY","amount":18990,"status":"AUTHORIZED",
       "buy_order":"orden-8891","session_id":"atbww0s5oEf2Sb-HN2fVLo-a8Q_4fhPV4qQF-jvv0gij7NvA",
       "card_detail":{"card_number":"6623"},"accounting_date":"0902",
       "transaction_date":"2026-09-02T15:55:33.841Z","authorization_code":"1213",
       "payment_type_code":"VN","response_code":0,"installments_number":0}}'
```

The endpoint requires the `events:write` scope.

---

## What Atribu does with it

| Webpay field | becomes |
| --- | --- |
| `amount` | the conversion value (CLP unless you pass `currency`) |
| `transaction_date` | the event time — what the attribution window is measured against |
| `session_id` | the visitor: `anonymous_id` + `session_id` |
| `buy_order` + `authorization_code` | the idempotency key |
| `status` + `response_code` | whether this is revenue at all |

Everything else on the commit response — `vci`, `payment_type_code`, `installments_number`, the last four card digits — is stored verbatim for later reconciliation.

---

## Verify the passthrough

The response tells you whether the reference actually arrived:

```json
{
  "data": {
    "status": "accepted",
    "attribution_carrier": "session_id",
    "anonymous_id": "anon_c34b39a047f649bf873767d52e8f9af1",
    "conversion": { "matched": true, "revenue_type": "cash" }
  }
}
```

- `attribution_carrier: null` — the reference never made it. Check that `Transaction.create()` really sent `getAttributionRef()` as `session_id`.
- `conversion.matched: false` — the payment was stored but no conversion goal claims `payment_received`, so it will never reach ROAS.

---

## Things worth knowing

**Only authorized sales become revenue.** A commit whose `status` is not `AUTHORIZED`, or whose `response_code` is not `0`, comes back as `{"status": "ignored", "reason": "…"}` with nothing written. Post every commit you receive; Atribu decides which are sales.

**Retries are free.** The event is filed under `<buy_order>_<authorization_code>`, so a repeated commit — or a `Transaction.status()` poll you forward instead — collapses onto the same conversion.

**Your monthly report still works, and will not double-count.** The webpay.cl PDF/CSV importer builds that same key from the report's OC and "Código de autorización" columns, so a payment already reported live is recognised as a duplicate.

**The `return_url` query string is not a carrier.** Transbank documents nothing about whether parameters appended to it survive, and the aborted-payment return in the integration environment is a POST. Use `session_id`.

**Oneclick Mall has no `session_id`.** Its only round-trip field is `buy_order` (26 characters, and it must be unique per transaction). Use `getAttributionRef(26)` there, or keep the `atb1.` token in your own session keyed on `tbk_user` and send it as `attribution_token`.

---

## Sources

- [Webpay API reference](https://www.transbankdevelopers.cl/referencia/webpay) — field lengths, the commit/status response shape
- [Webpay Plus documentation](https://www.transbankdevelopers.cl/documentacion/webpay-plus) — the four return flows
- [Getting started](https://www.transbankdevelopers.cl/documentacion/como_empezar) — integration-environment credentials and test cards

---

# Shopify

The Shopify integration connects your online store to Atribu. It tracks orders and customer data so you can see which ad campaigns drive the most sales.

---

## What you get

- Orders recorded as conversion events with revenue data
- Customer information (email, name) linked to Atribu's identity graph
- Attribution of e-commerce revenue to specific ad campaigns
- **Full storefront tracking** via the Atribu theme extension (engagement, scroll depth, video, forms, web vitals)
- **Checkout event tracking** via the Shopify Web Pixel (add to cart, checkout started, purchase)
- Webhooks for `orders/paid`, `orders/create`, and `checkouts/create`

---

## How the dual-track setup works

Atribu uses two complementary extensions on your Shopify store:

| Extension | Where it runs | What it captures |
|---|---|---|
| **Theme extension** (Atribu Tracker) | Storefront pages (product, collection, home, blog) | Engagement, scroll depth, video, forms, identity, web vitals, UTMs, click IDs |
| **Web Pixel** | Sandboxed checkout | `add_to_cart`, `checkout_started`, `purchase` with order value |

The theme extension gives you full behavioral analytics on your storefront. The web pixel handles the checkout, where Shopify does not allow external scripts to run directly.

Both share the same `anonymous_id` and `session_id` in localStorage, so a visitor's browsing behavior is linked to their eventual purchase.

<Callout type="info" title="Why two extensions?">
Shopify's checkout runs in a sandboxed environment that blocks external scripts. The Web Pixel API is the only way to track events there. For everything else (storefront pages), the full Atribu tracker provides much richer data than the pixel alone.
</Callout>

---

## Connecting Shopify

<Steps>
<Step>

## Open Integrations

Go to **Settings > Integrations** in your Atribu workspace.

</Step>
<Step>

## Enter your store domain

Type your Shopify store domain (e.g., `my-store` or `my-store.myshopify.com`) into the Shopify card.

</Step>
<Step>

## Authorize the app

Click **Connect**. You will be redirected to Shopify to install and authorize the Atribu app. Atribu requests:

- **Read orders** and **Read all orders** -- to track purchases and revenue
- **Read customers** -- to match buyers to ad clicks via the identity graph
- **Write pixels** -- to automatically activate the checkout pixel
- **Read customer events** -- to read pixel event data

</Step>
<Step>

## Enable storefront tracking

After connecting, click the **Enable storefront tracking** button. This opens your Shopify theme editor directly to the Atribu Tracker toggle.

1. Toggle **Atribu Tracker** ON if it is not already enabled
2. Your tracking key is pre-filled
3. Click **Save** if Shopify shows it as enabled; if **Save** is disabled while the toggle is already ON, the activation is already saved

<Callout type="warn" title="Don't skip this step">
Without the theme extension, you only get checkout events from the web pixel. You miss storefront page views, form-based identity capture, and UTM/click-ID collection — the data that lets a later purchase be traced back to the ad that drove it.
</Callout>

</Step>
<Step>

## Verify

Back in Atribu, the Shopify card should show **Connected**. The web pixel is activated automatically during the connection step.

Check **Settings > Customer events** in your Shopify admin to confirm the Atribu pixel shows **Connected**.

</Step>
</Steps>

---

## What syncs

| Data | Source | Description |
|---|---|---|
| **Page views** | Theme extension | Every storefront page visit with UTMs and click IDs |
| **CTA visibility** | Theme extension (opt-in, off by default) | `cta_viewed` when a tracked call-to-action enters the viewport — the only Features checkbox that creates a real, attributed event |
| **Add to cart** | Web Pixel | Product added to cart with value |
| **Checkout started** | Web Pixel | Checkout initiated with subtotal |
| **Purchase** | Web Pixel + Webhook | Completed order with total value, order ID, checkout token |
| **Order details** | Webhook | Order amount, currency, customer email/name, product data |
| **Customer identity** | Theme extension + Webhook | Email from forms (via tracker `identify()`) and from orders |

---

## How attribution works

1. A customer clicks your ad and arrives on your Shopify store (with UTM params or click IDs)
2. The **Atribu theme tracker** records the visit, engagement, and captures the ad attribution data
3. If the customer fills out a form (email signup, contact form), `identify()` links their anonymous visit to their email
4. The customer makes a purchase -- the **web pixel** fires `purchase`, and the **webhook** delivers the order with customer email
5. Atribu matches the customer's email to the earlier storefront visit
6. Revenue is attributed to the ad campaign that drove the original click

### The Features checkboxes (Click Quality, Web Vitals, CTA Visibility, Video Tracking)

These are opt-in and off by default. Click Quality, Web Vitals, and Video Tracking emit
display-only `engagement` events that never become a conversion and never carry
attribution credit — the server drops every event named `engagement` unconditionally,
regardless of which of these you enable. CTA Visibility is the one exception: it emits
`cta_viewed`, a real tracked event that becomes an attributed Touch (though never a
conversion on its own — viewing a CTA is an intent signal, not a sale).

---

## Theme extension settings

The Atribu Tracker app embed supports these settings in the Shopify theme editor:

| Setting | Default | Description |
|---|---|---|
| Tracking Key | *(required)* | Your `trk_` key from Atribu Settings > Tracking |
| Tracking Endpoint | `https://www.atribu.app/api/tracking/collect` | Change only if using a custom domain |
| Tracker Script URL | `https://www.atribu.app/atribu-tracker.js` | Change only if self-hosting |
| Intercept Meta Pixel | ON | Mirror `fbq()` events server-side for better Meta CAPI match quality |
| Session Timeout | 30 minutes | Minutes of inactivity before a new session starts |
| Session Mode | Inactivity or source change | Also starts a new session when the visitor arrives from a different campaign |
| Click Quality | OFF (opt-in) | Track rage clicks and dead clicks. Never becomes a conversion or attribution credit. |
| Web Vitals | OFF (opt-in) | Capture LCP, CLS, INP performance metrics. Never becomes a conversion or attribution credit. |
| CTA Visibility | OFF (opt-in) | Track when call-to-action buttons enter the viewport. The only one of these four that creates a real, attributed Touch. |
| Video Tracking | OFF (opt-in) | Track native video, Wistia, and Video.js playback. Never becomes a conversion or attribution credit. |
| Cart Attribution | ON | Stamp the visitor's session and ad-click context as Shopify cart attributes so orders stay attributable when the checkout pixel is blocked |

---

## Manual mode (no app install)

Some stores cannot install a third-party app: agency-managed client stores, or
Shopify Plus organizations with an app-approval policy. Those stores can still
send Atribu their orders, using webhooks the merchant creates by hand in the
Shopify admin. **This is a reduced tier — read "What you give up" before
choosing it.**

<Steps>
<Step>

## Create the endpoint in Atribu

Go to **Settings > Integrations**, open the Shopify card, and click **Connect
without installing the app**. Enter the store's `.myshopify.com` domain (not
your custom storefront domain — Shopify signs deliveries with the
`.myshopify.com` name) and click **Create endpoint**.

Atribu generates a webhook URL unique to this store. Copy it.

</Step>
<Step>

## Create the webhooks in Shopify

In the Shopify admin, go to **Settings > Notifications > Webhooks > Create
webhook**. Create one webhook for each of these events, all pointing at the URL
Atribu gave you:

| Shopify event | Why |
|---|---|
| **Order creation** | Records the order as it is placed |
| **Order payment** | Records the captured payment — this is the revenue event |
| **Refund create** | Reverses the sale so ROAS reflects money kept |

For each one: format **JSON**, and pick the **API version** Atribu shows on the
setup card (it matches whatever the app itself is on).

</Step>
<Step>

## Paste the store's signing ID

Once at least one webhook exists, Shopify shows a **signing ID** at the bottom
of that same Webhooks page. This is a per-store secret — it is **not** an app
secret, and it is not the same value for any two stores.

Copy it into the **signing ID** field on the Atribu setup card. Every delivery
is verified against it; until it is set, deliveries are rejected.

</Step>
<Step>

## Add the tracker to your theme

There is no theme app extension without an app install, so the tracker goes in
by hand: **Online Store > Themes > Edit code > `theme.liquid`**, and paste the
snippet Atribu shows just before the closing `</head>` tag.

Skipping this step is not cosmetic. Without the tracker, orders arrive with no
ad attribution at all — Atribu can tell you a sale happened, but not which ad
caused it.

</Step>
<Step>

## Verify

Place a test order. The Atribu setup card flips from **Awaiting first delivery**
to **Receiving** on the first webhook Atribu verifies, and shows the timestamp
of the last delivery.

</Step>
</Steps>

### What you give up

| | App install | Manual mode |
|---|---|---|
| Order + refund capture | Yes | Yes |
| Storefront tracking (theme) | App embed, one toggle | Paste into `theme.liquid` |
| Checkout web pixel | Yes | **No** — `add_to_cart` and `checkout_started` are not captured |
| Product / inventory sync | Yes | **No** — needs Admin API access |
| Billing | Shopify Billing | **Stripe**, like any non-Shopify customer |
| Broken-connection alert | App tells us | **Silence only** — see below |
| Customer data in deliveries | Yes | **Yes, unredacted** — email, name, addresses, browser IP and user agent arrive exactly as with the app |

**Manual mode is not a privacy-lighter option.** Admin-created webhooks carry the full order payload — we verified this on a live store: `email`, `customer.*`, `billing_address`, `client_details.browser_ip` and `user_agent` are all present. If you chose manual mode to keep customer data out of a third party, it does not do that.

**Deleting the webhook in Shopify stops capture silently.** Nothing tells Atribu
it happened, so nothing can tell you immediately. Shopify also retires an
admin-created webhook on its own after repeated delivery failures (and emails
the store owner, not us). Atribu's only signal is the absence of deliveries: the
setup card flips to **Gone quiet** after 14 days without one. If you change the
store's webhook configuration, check the card afterwards.

<Callout type="warn" title="Prefer the app install">
Every degradation above disappears with the App Store install, and the install
takes less time than this setup does. Use manual mode only when installing an
app is genuinely not an option.
</Callout>

---

## Troubleshooting

### Orders are not syncing

- Check that the connection shows **Connected** in **Settings > Integrations**
- Make sure the Atribu app is still installed in your Shopify admin under **Settings > Apps and sales channels**
- Verify webhooks are registered: **Settings > Notifications > Webhooks** in Shopify admin
- If you recently changed your store domain, reconnect from Atribu

### Web pixel shows "Disconnected"

The web pixel is activated automatically when you connect Shopify from Atribu. If it shows disconnected:

- Reconnect from Atribu (Settings > Integrations > Shopify > Reconnect)
- Check **Settings > Customer events** in Shopify admin

### No engagement data on storefront

- Make sure the **Atribu Tracker** app embed is toggled ON in the theme editor
- Verify the tracking key is correct
- Check the browser console for errors on a storefront page

### Revenue is not attributed to campaigns

For attribution to work, the customer must be identifiable. This requires either:

- The Atribu theme tracker capturing the ad click (UTMs/click IDs) on the storefront visit
- The customer's email matching a previously identified visitor in Atribu

If neither condition is met, the order will be recorded but shown as unattributed.

### Deduplication

The web pixel and webhooks may both capture the same purchase. Atribu deduplicates by `order_id` and `checkout_token` -- you will never see double-counted revenue.

---

## Related

<Cards>
  <Card title="Revenue Types" href="/docs/concepts/revenue-types">
    How order values (gross) differ from cash payments
  </Card>
  <Card title="Identity Resolution" href="/docs/concepts/identity-resolution">
    How Shopify customer emails are matched to ad clicks
  </Card>
  <Card title="Engagement-Weighted Attribution" href="/docs/concepts/attribution-models">
    How storefront engagement quality affects credit distribution
  </Card>
</Cards>

---

# Google Search Console

The Google Search Console (GSC) integration shows you which search queries bring visitors to your site organically -- without paid ads. This helps you understand your SEO performance alongside your paid campaigns.

---

## What you get

- Search queries that drive organic traffic to your site
- Performance metrics: impressions, clicks, CTR, and average position per query
- Visibility into which keywords bring free traffic vs paid traffic

---

## Connecting Google Search Console

<Steps>
<Step>

## Open Integrations

Go to **Settings > Integrations** in your Atribu workspace.

</Step>
<Step>

## Start the connection

Click **Connect** next to the Google Search Console card. You will be redirected to Google.

</Step>
<Step>

## Sign in with Google

Sign in with the Google account that has access to your Search Console properties. Grant Atribu permission to read your search data.

</Step>
<Step>

## Select your property

Choose the Search Console property (website) you want to sync. This should match the domain you are tracking with Atribu.

<Callout type="info" title="Property types">
If you have both a **domain property** (e.g., `example.com`) and a **URL-prefix property** (e.g., `https://www.example.com/`), choose the domain property for the most complete data.
</Callout>

</Step>
</Steps>

---

## What syncs

| Data | Description |
|------|-------------|
| **Search queries** | The actual keywords people search for on Google |
| **Impressions** | How many times your site appeared in search results for each query |
| **Clicks** | How many times someone clicked through to your site |
| **CTR** | Click-through rate (clicks / impressions) |
| **Average position** | Where your site typically ranks for each query (1 = top result) |

---

## Where the data appears

Search Console data appears in the **keyword breakdown** section of your dashboard. This gives you a clear picture of which search terms bring organic visitors, separate from your paid traffic sources.

<Callout type="info" title="Organic vs Paid Search">
Google Search Console shows your **organic** (free) search traffic. This is separate from **Google Ads**, which shows your paid search campaigns. Together, they give you a complete picture of your Google search presence -- both the traffic you pay for and the traffic you earn through SEO.
</Callout>

---

## Troubleshooting

### No data is showing

Google Search Console data can take 2-3 days to become available -- this is a limitation of Google's reporting, not Atribu. If your property was recently verified, wait a few days for data to accumulate.

### I do not see my property

- Make sure you are signed in with the Google account that owns or has access to the Search Console property
- Verify that the property is set up and verified in [Google Search Console](https://search.google.com/search-console)
- If you manage multiple properties, check that you selected the correct one during setup

### Data seems incomplete

Google Search Console does not report queries with very low volume for privacy reasons. If a query generated only one or two clicks, it may not appear in the data. This is normal behavior from Google's side.

---

## Related

<Cards>
  <Card title="Traffic Sources" href="/docs/features/traffic-sources">
    See keyword data in your dashboard breakdown boards
  </Card>
  <Card title="Channel Classification" href="/docs/concepts/channels">
    How organic search traffic is classified and tracked
  </Card>
</Cards>

---

# Conversion Goals

Conversion goals tell Atribu which events matter for your business. When a tracked event matches a goal, it becomes a **conversion** that gets attributed to your ad campaigns.

## Default goals

When you create a new profile, Atribu automatically sets up these goals:

| Goal | Event Type | Revenue Type | Description |
|------|-----------|-------------|-------------|
| Payment Received | `payment_received` | Cash | Stripe or MercadoPago payment completed |
| Lead Created | `lead_created` | Pipeline | New lead in GHL |
| Appointment Booked | `appointment_booked` | Pipeline | Calendar booking completed |
| Closed Won | `closed_won` | Pipeline | Deal marked as won in GHL |
| Order Placed | `order_placed` | Gross | Shopify or e-commerce order |
| Contact Reply | `contact_reply` | Pipeline | Customer responded |

## Revenue types

Each goal has a **revenue type** that determines how its value is counted:

<Callout type="warn" title="Revenue types matter for ROAS">
Only **Cash** revenue is used for ROAS calculations. Pipeline and Gross values are tracked separately.
</Callout>

| Revenue Type | What it means | Used for ROAS? |
|-------------|--------------|----------------|
| **Cash** | Real money received (Stripe, MercadoPago) | Yes |
| **Pipeline** | Projected deal value from CRM (GHL) | No |
| **Gross** | Order value before refunds/cancellations | No |

## Creating custom goals

<Steps>
<Step>

Go to **Settings > Outcomes** tab

</Step>
<Step>

Click **Add Goal**

</Step>
<Step>

Configure the goal:
- **Name**: Display name (e.g., "Free Trial Started")
- **Source events**: Which event types trigger this goal (e.g., `trial_started`)
- **Revenue type**: Cash, Pipeline, or Gross
- **Attribution eligible**: Whether this goal should be included in attribution models

</Step>
<Step>

Click Save. New conversions matching this goal will be created automatically.

</Step>
</Steps>

## GHL pipeline stage mapping

If you use GoHighLevel, Atribu maps your pipeline stages to conversion types:

- "New Lead" stages → `lead_created`
- "Appointment" stages → `appointment_booked`
- "Won" / "Closed" stages → `closed_won`

You can customize this mapping in **Settings > Outcomes**. Atribu uses keyword matching to suggest initial mappings, but you can override them.

<Callout type="info" title="Automatic trigger">
When a new event arrives (from Stripe, GHL, or the tracker) that matches a goal's source event types, Atribu automatically creates a conversion and runs attribution. No manual action needed.
</Callout>

## Related

<Cards>
  <Card title="Revenue Types" href="/docs/concepts/revenue-types">
    How cash, pipeline, and gross revenue types differ
  </Card>
  <Card title="Custom Events" href="/docs/tracking/custom-events">
    Track custom actions and use them as conversion goals
  </Card>
  <Card title="Attribution Models" href="/docs/concepts/attribution-models">
    How credit is distributed across touchpoints for each conversion
  </Card>
</Cards>

---

# Attribution Windows

Attribution windows define the maximum time between a marketing touchpoint (like an ad click) and a conversion (like a payment) for the touchpoint to receive credit.

## Default windows

| Window | Default | Range | What it controls |
|--------|---------|-------|-----------------|
| **Click window** | 30 days | 1-365 days | How long after an ad click can a conversion be attributed |
| **View-through window** | 24 hours | 1-168 hours | How long after seeing an ad (without clicking) can a conversion be attributed |
| **First-touch window** | 90 days | 1-365 days | Maximum lookback period for the first-touch attribution model |

## How windows work

When a customer converts (e.g., makes a payment), Atribu looks back in time for touchpoints:

- If the customer clicked an ad **within the click window** (default 30 days), the touchpoint gets credit
- If the customer saw an ad **within the view-through window** (default 24 hours) but didn't click, the impression may get credit
- For first-touch attribution, Atribu looks as far back as the **first-touch window** (default 90 days) to find the original discovery touchpoint

<Callout type="info" title="Example">
Your click window is 30 days. A customer clicks your Facebook ad on March 1, browses your site, leaves, and comes back on March 25 to buy. The purchase IS attributed to the Facebook ad (25 days < 30-day window). If they buy on April 5, it's NOT attributed (35 days > 30-day window).
</Callout>

## Configuring windows

<Steps>
<Step>
Go to **Settings > General** (or Settings > Tracking)
</Step>
<Step>
Find the **Attribution Windows** section
</Step>
<Step>
Adjust the values for your business:
- **Short sales cycle** (e-commerce): 7-14 day click window
- **Medium sales cycle** (SaaS, services): 30 day click window (default)
- **Long sales cycle** (B2B, high-ticket): 60-90 day click window
</Step>
<Step>
Click Save. Changes apply to future attribution calculations.
</Step>
</Steps>

<Callout type="warn" title="Changing windows affects ROAS">
Wider windows = more conversions attributed = higher apparent ROAS. Narrower windows = fewer attributions = more conservative ROAS. Choose based on your actual sales cycle, not the number you want to see.
</Callout>

## Best practices

- **Match your sales cycle**: If most customers buy within 2 weeks, a 14-day window is more accurate than 90 days
- **Be consistent**: Changing windows frequently makes period-over-period comparisons unreliable
- **Use view-through conservatively**: A 24-hour view-through window means someone who saw (but didn't click) your ad yesterday gets credit if they buy today. This can inflate attribution.

## Recommendation from your sales cycle

For every customer whose first payment landed in the last 12 months, Atribu
measures how many days passed from their **first ad** to that payment. With 10
or more measured customers it recommends:

- **First touch**: a window covering 80% of those customers (at most 365 days).
- **Click**: a window covering the median (at most 90 days). A year-long click window would let an old click take credit from the click that closed the sale; the long tail is the first-touch window's job.

The recommendation never shortens a window. It shows above the fields with an
«Apply recommendation» button, which saves exactly like «Save» and recomputes
your numbers. `GET /api/v1/profile/attribution-settings/recommendation` returns
the same measurement (scope `analytics:read`).

## Cash window

For high-ticket sales that close long after the ad click, `cash_window_days`
overrides the click/first-touch windows for **cash** conversions only. Leads
and appointments always use the normal windows above; only a `cash`
conversion checks this wider window when it is set. Leave it unset (`null`,
the default) to use the normal windows for cash too.

## API

`GET` / `PATCH /api/v1/profile/attribution-settings` read and write the same
four windows this page configures. **Scope:** `analytics:read` for the GET,
`goals:write` for the PATCH.

### Get the current windows

```http title="Endpoint"
GET /api/v1/profile/attribution-settings
```

<Tabs items={["cURL", "JavaScript"]}>
<Tab value="cURL">
```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/profile/attribution-settings"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
import { AtribuClient } from "@atribu/node";

const client = new AtribuClient({ apiKey: "atb_live_YOUR_KEY" });
const settings = await client.profile.attributionSettings();
```
</Tab>
</Tabs>

```json title="Response (200 OK)"
{
  "data": {
    "click_window_days": 30,
    "view_window_hours": 24,
    "first_touch_window_days": 90,
    "cash_window_days": null
  },
  "meta": { "profile_id": "..." }
}
```

A profile with no attribution-settings row yet returns these same defaults —
there is nothing to configure before your first change.

### Update the windows

```http title="Endpoint"
PATCH /api/v1/profile/attribution-settings
```

A **partial** update — send only the fields you want to change; an omitted
key is left untouched. Send `"cash_window_days": null` to clear the override.

Every successful call queues a full-profile attribution replay, since a
window change redefines which touches attach to which conversion
retroactively — reported back as `replay_queued` (and `replay_error` when the
replay could not be queued; the setting itself is still saved).

<Tabs items={["cURL", "JavaScript"]}>
<Tab value="cURL">
```bash title="cURL"
curl -X PATCH \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"click_window_days": 14}' \
  "https://www.atribu.app/api/v1/profile/attribution-settings"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
const result = await client.profile.updateAttributionSettings({
  click_window_days: 14,
});
```
</Tab>
</Tabs>

```json title="Response (200 OK)"
{
  "data": {
    "click_window_days": 14,
    "view_window_hours": 24,
    "first_touch_window_days": 90,
    "cash_window_days": null,
    "replay_queued": true,
    "replay_error": null
  },
  "meta": { "profile_id": "..." }
}
```

An out-of-range value answers `400 invalid_parameter`, naming the field:

```json title="Response (400 Bad Request)"
{
  "error": {
    "code": "invalid_parameter",
    "message": "click_window_days must be an integer between 1 and 365",
    "status": 400,
    "request_id": "..."
  }
}
```

<Callout type="info" title="Session/enrichment settings are not here">
  This endpoint publishes only the four windows above. Session timeout, geo/
  device enrichment, channel classification, and export destinations are
  configured in **Settings > Tracking** and have no `/api/v1` surface yet.
</Callout>

## Related

<Cards>
  <Card title="Attribution Models" href="/docs/concepts/attribution-models">
    How credit is distributed across touchpoints within the window
  </Card>
  <Card title="Ads Performance" href="/docs/features/ads-performance">
    See how window changes affect campaign ROAS
  </Card>
</Cards>

---

# Team & Workspace

Atribu uses a two-level organization: **Workspaces** contain **Profiles**. Each workspace can have multiple team members with different roles.

## Workspaces vs Profiles

| Level | What it is | Example |
|-------|-----------|---------|
| **Workspace** | Your organization or agency | "Acme Marketing Agency" |
| **Profile** | A specific business/client being tracked | "Client A - E-commerce Store" |

One workspace can have multiple profiles — perfect for agencies managing multiple clients.

## Team roles

| Role | Dashboard | Settings | Integrations | API Keys | Members |
|------|-----------|----------|-------------|----------|---------|
| **Admin** | View | Edit | Manage | Create/Revoke | Invite/Remove |
| **Editor** | View | Edit | View | Create | View |
| **Viewer** | View | View | View | No | View |

## Inviting team members

<Steps>
<Step>
Go to **Workspace Settings** (click workspace name in sidebar)
</Step>
<Step>
Click the **Members** tab
</Step>
<Step>
Enter the email address and select a role
</Step>
<Step>
Click **Invite**. They'll receive an email to join.
</Step>
</Steps>

<Callout type="info" title="Profile-level access">
You can also manage access per profile. Go to the profile's **Settings > Members** to control who can see which client's data.
</Callout>

## Audit logs

Workspace admins can view an activity log of all actions taken by team members:

- Go to **Workspace Settings > Audit Logs**
- See who did what and when (connection changes, settings updates, key creations)
- Last 50 events are retained

## Workspace settings

In **Workspace Settings > General**:

- **Workspace name**: Your organization name
- **Timezone**: Default timezone for new profiles

## Related

<Cards>
  <Card title="API Authentication" href="/docs/api/authentication">
    Create and manage API keys for programmatic access
  </Card>
  <Card title="Reports" href="/docs/features/reports">
    Generate shareable reports for team members and clients
  </Card>
</Cards>

---

# AI Media Buyer

The AI Media Buyer watches your ad accounts every day and proposes concrete, dollar-estimated actions: scale this winner, pause that underperformer, move budget from laggards to leaders, refresh a creative before it fatigues.

It is deliberately split into two halves. **Rules decide the "what"** — every action comes from a fixed set of four action types with hard numeric guardrails, so the AI can never invent an action or exceed a limit. **AI writes the "why"** — a language model prioritizes the list and explains each recommendation in plain prose, backed by your actual attribution data.

Nothing is ever executed on its own. Every change to your ad account requires an explicit confirmation from a workspace owner or admin.

## What it does

Once a day, after the daily creative scoring run, the engine:

1. Scores every ad against its **cohort** — ads with the same channel, format, objective, audience warmth, geo, and placement — so a $50/day lead-gen ad is never judged against a $5,000/day e-commerce ad
2. Generates candidate actions from the four action types below, applying every guardrail
3. Prioritizes and explains the surviving candidates, capped at **10 per action type** and **12 per workspace**
4. Publishes them to the Recommendations page, the notification bell, and your morning briefing

Each recommendation card shows the action, the reasoning, an **estimated dollar impact over the next 7 days**, and a **confidence percentage**. Recommendations you don't act on expire after 30 days; if the data changes, they are superseded by fresh ones.

## The four actions

| Action | What it changes | Limits | Risk tier | Meta calls |
|--------|----------------|--------|-----------|------------|
| **Scale a winner** | Raises the ad set's daily budget | +25% by default, always between +10% and +50% | Medium — explicit confirm | One budget update |
| **Pause an underperformer** | Sets the ad to paused | Bottom 10% of its cohort only, and only after $50+ spend in 28 days | Safe — reversible by unpausing | One status update |
| **Reallocate budget** | Moves daily budget from lagging ad sets to leading ones | 20% shift by default, between 10% and 35% per leg; budget-neutral; never drops an ad set below $1/day | Medium — explicit confirm | One budget update per ad set |
| **Refresh creative** | Nothing in your ad account | Only fires on ads that were performing well and now show high fatigue risk | Manual only | None |

### When each action fires

- **Scale** — the ad's calibrated top-performer probability is at least 0.75, the forecast shows a meaningful lift over its current pace, and its refund rate is under 10%. Ads that already take 30% or more of the account's spend are never scaled further.
- **Pause** — the ad's composite score sits in the bottom 10th percentile of its cohort. Ads with less than $50 of spend in the last 28 days are exempt (they haven't had a fair shot), and ads whose first attributed conversion landed within the last 14 days are protected from pausing.
- **Reallocate** — leaders need a top-performer probability of at least 0.7; laggards must score below their cohort's 25th percentile. The legs are sized so the money freed from laggards equals the money added to leaders — your total daily budget stays the same.
- **Refresh** — the ad scores 70 or higher (it was working) but the model predicts a high risk it fatigues soon. Applying this recommendation makes **zero changes in Meta** — it marks the recommendation as handled and directs you to Ads Lab to produce replacement creative.

<Callout type="info" title="What the AI can and cannot do">
The language model reranks the candidate list, writes the prose rationale, and can nudge the two percentages (scale amount, reallocation shift) within their clamps. It cannot invent new action types, change which ad is targeted, or exceed any guardrail. If the workspace's monthly AI budget is exhausted, recommendations still ship — with rules-based explanations instead of AI prose.
</Callout>

## Nothing happens without your approval

Every apply follows the same loop:

<Steps>

<Step>
### Preview

Clicking **Apply** opens a preview first — never a direct write. The preview shows the action type, its risk tier, the exact Meta call(s) that will be made, the budget percentage, the estimated dollar impact, and the confidence.
</Step>

<Step>
### Confirm

Only a workspace **owner or admin** can confirm. Members and guests can see recommendations but cannot apply them.
</Step>

<Step>
### Execute

Before writing anything, Atribu fetches the ad's **current live state from Meta** (status and budget) and records it. Budget changes are computed against those live values — never against stale snapshots — so a budget you edited manually an hour ago is respected.
</Step>

<Step>
### Verify

About 5 minutes after the apply, Atribu re-reads the ad from Meta and checks that the change stuck. The card shows a **Pending verification** badge that flips to **Verified** on match. If Meta's state doesn't match what was expected — for example, someone edited the budget in Ads Manager in between — the card shows a warning instead.
</Step>

</Steps>

### Safety rails on execution

- **One at a time** — applies are rate-limited to one per minute per workspace, and a concurrency lock prevents double-clicks or two people applying the same recommendation simultaneously.
- **Safe retries** — every apply carries an idempotency key. Retrying a request that already succeeded returns the existing result; it never triggers a second Meta write.
- **Partial failures block re-apply** — if a multi-step apply partially failed against Meta, you must roll back or retry the failed steps before applying again. Atribu refuses to blindly re-run over a prior write.
- **Circuit breaker** — 3 failed Meta calls within 30 minutes pause all applies for the workspace until the window clears.
- **Fresh data only** — a recommendation cannot be applied if its underlying score is more than 12 hours old.

<Callout type="warn" title="No autopilot">
There is no automatic execution mode. Every Meta write today — including "safe"-tier pauses — is confirmed by a human before it runs.
</Callout>

## Your morning briefing

The AI Media Buyer surfaces its findings through three channels:

### Notification bell

The bell in the top bar collects workspace alerts:

| Alert | What it means |
|-------|---------------|
| **Spend anomaly** | Unusual spend detected on a campaign or ad |
| **Fatigue flip** | An ad's fatigue prediction turned critical |
| **New recommendation** | Fresh recommendations landed for a profile |
| **Draft ready** | A Replicate creative draft finished generating |

Alerts are deduplicated per day, so a persistent anomaly won't flood your bell.

### Daily digest email

A daily email summarizes the day's alerts across your workspace.

### Morning brief (opt-in)

An optional AI-written narrative email, delivered in the early morning in your workspace's timezone. It covers:

- Yesterday's alerts
- Ads at risk, with the estimated daily spend at risk
- Your **top 3 open recommendations** with their estimated dollar impact

Each user opts in individually. If the workspace had a quiet day — no alerts, nothing at risk, no open recommendations — no email is sent.

## Audit trail and reversibility

- **Logged before it runs** — every confirmed apply writes an append-only audit record (who, what, the exact payload) *before* the Meta call is made. Workspace members can read the audit trail; no one can edit it from the app.
- **Pre-change state captured** — the ad's live status and budget are recorded before every write, so the previous value is always known.
- **Pause is reversible** — undo a pause by setting the ad live again in Meta Ads Manager. That reversibility is why pausing is the only "safe"-tier action.
- **Reallocations roll back in one click** — a multi-ad-set reallocation has a **Roll back all** action (owner/admin) that restores every applied ad set to its captured prior budget. If a two-sided reallocation fails halfway, the completed half is rolled back automatically.
- **Scaled budgets** — the prior budget is recorded in the audit trail, but there is no one-click undo for scale actions; restore the value in Meta Ads Manager if needed.

## Where to find it

- **Recommendations page** — in the workspace sidebar. Filter by time window (7d / 14d / 28d / lifetime), action type, and status (open, applied, dismissed, superseded, expired, rolled back). Recommendations span all client profiles in the workspace; guests only see profiles they are members of.
- **Top Performers page** — the scoring layer the recommendations are built on, also in the workspace sidebar.
- **Notification bell and email** — as above.
- **REST API** — `GET /api/v1/recommendations` and an apply endpoint. Applying requires the `campaigns:apply` scope, which is **not** granted by default — a workspace admin must opt each API key in — plus an `Idempotency-Key` header on every apply.
- **MCP (Claude and other agents)** — `apply_recommendation` with `preview`, `dry_run`, and `confirm` modes. Applying through MCP requires all three of: a token with the `mcp:write` scope, the workspace's write-back setting enabled, and an admin or owner role.

## What it never touches

The engine refuses to recommend actions on:

- Ads in **unknown cohorts** — if it can't classify the ad, it won't judge it
- **New or immature ads** still in their learning phase
- Ads whose **first conversion just landed** (14-day protection)
- **High-refund ads** (as scale candidates)
- Ads that are **already paused**
- Accounts where **Meta itself recently paused something** — Atribu backs off rather than fighting the platform
- Winners already at **30%+ of account spend** (for scaling)
- Anything scored more than **12 hours ago**

And it never, under any circumstances: applies a change without human confirmation, deletes ads or campaigns, or edits targeting or creative in Meta.

## Related

- [Conversion Sync](/docs/features/conversion-sync) — feed real outcomes back to Meta so the scoring has better signal
- [Goals (Conversion Definitions)](/docs/settings/goals) — define the outcomes the engine optimizes toward
- [Attribution Models](/docs/concepts/attribution-models) — how conversion credit behind the scores is assigned
- [Team & Workspace](/docs/settings/team) — roles that control who can confirm applies

---

# Dashboard

The dashboard is your mission control. It brings together every important marketing metric into a single view so you can understand how your campaigns are performing right now and how that compares to the previous period.

## KPI metrics bar

The top of the dashboard shows your key performance indicators side by side. Each metric compares the current period to the previous period of equal length.

| Metric | What it means |
|--------|--------------|
| **Visitors** | Unique people who visited your website in this period. |
| **Revenue** | Cash that came in from ad-attributed conversions (Stripe or MercadoPago payments only). |
| **Spend** | Total amount spent on ads across all connected platforms. |
| **CAC** | Customer Acquisition Cost — your ad spend divided by the number of conversions. Lower is better. |
| **Bounce Rate** | Percentage of visitors who left after viewing only one page. |
| **Online Now** | People currently browsing your site (updates every 30 seconds). |

Each metric shows a percentage change compared to the previous period. Green means the number improved; red means it went in the wrong direction.

<Callout type="info" title="Revenue only counts real payments">
Revenue for ROAS calculations only includes confirmed cash payments from Stripe and MercadoPago. Pipeline values from your CRM (like deal estimates) are shown separately and never mixed into ROAS.
</Callout>

## Date range picker

Select any time period and Atribu automatically compares it to the previous period of the same length. For example, if you select the last 7 days, the comparison period is the 7 days before that.

Quick presets are available for common ranges: last 7 days, last 14 days, last 30 days, or a custom range.

## Attribution model selector

Choose how credit is assigned to your marketing touchpoints using the dropdown in the header. Switching models instantly recalculates revenue, ROAS, and campaign rankings across the entire dashboard.

<Callout type="info" title="Learn more about models">
See [Attribution Models](/docs/concepts/attribution-models) for a detailed explanation of each model and when to use it.
</Callout>

## Main chart

The main chart is a dual-axis visualization showing two metrics over time:

- **Blue line**: Visitors per day (left axis)
- **Amber bars**: Cash revenue per day (right axis)

Hover over any day to see exact numbers in a tooltip. The tooltip also shows ad spend for that day when available. You can toggle either series on or off by clicking the colored square next to its label in the metrics bar.

## Breakdown boards

Below the main chart, four boards break down your traffic by different dimensions. Each board has multiple tabs:

| Board | Tabs |
|-------|------|
| **Traffic Sources** | Channel, Referrer, Campaign, Keyword |
| **Geography** | Country, Region, City |
| **Content** | Page, Entry Page, Exit Page |
| **Technology** | Browser, OS, Device |

Each row shows visitors, visits, pageviews, bounce rate, conversions, and revenue. Click any row to see a detailed breakdown dialog with the full data.

The Traffic Sources board also includes a donut chart that visualizes channel distribution at a glance.

## Top campaigns

The bottom of the dashboard shows your highest-performing campaigns ranked by attributed revenue. Each campaign displays:

- Total spend and attributed revenue
- Number of conversion results
- ROAS (Return on Ad Spend)

Click **View All** to go to the full [Ads Performance](/docs/features/ads-performance) page.

## Dashboard filters

Use the filter bar below the date range picker to narrow down all dashboard data. Available filter dimensions include:

- **Channel** — Paid Social, Organic Search, Direct, etc.
- **Country** — filter by visitor country
- **Device** — Desktop, Mobile, Tablet
- **Campaign** — specific ad campaigns
- **Goal** — filter by conversion type (e.g., only show payment_received)

Filters apply to every section of the dashboard simultaneously — KPIs, charts, breakdowns, and top campaigns all update together.

## Connection health banner

When you have integrations connected (Meta, Google Ads, Stripe, GHL), a health banner shows next to your profile name. It indicates the sync status of each connection, letting you know if data is fresh or if any connection needs attention.

## Journey & Users card

At the bottom of the dashboard, a tabbed card lets you quickly browse customer journeys, visitor lists, and conversion funnels without leaving the page. See the [Customer Journeys](/docs/features/customer-journey) page for full details.

## Related

<Cards>
  <Card title="Attribution Models" href="/docs/concepts/attribution-models">
    How credit is distributed across touchpoints
  </Card>
  <Card title="Revenue Types" href="/docs/concepts/revenue-types">
    Why only cash payments appear in ROAS calculations
  </Card>
  <Card title="Reports" href="/docs/features/reports">
    Generate shareable performance reports from your dashboard data
  </Card>
</Cards>

---

# Traffic Sources

Atribu automatically classifies every visitor into a traffic channel based on how they arrived at your site. This helps you understand which marketing efforts drive the most traffic and conversions.

## Channel types

Every visit to your website is classified into one of these channels:

| Channel | What it means |
|---------|--------------|
| **Paid Social** | Visitors who clicked a paid ad on Facebook, Instagram, TikTok, or other social platforms. Identified by click IDs like `fbclid` or `ttclid`, or by UTM parameters with `medium=paid`. |
| **Organic Social** | Visitors from unpaid social media posts — someone shared your link on Facebook or you posted on Instagram without paying for promotion. |
| **Paid Search** | Visitors from Google Ads, Bing Ads, or other paid search campaigns. Identified by `gclid`, `msclkid`, or UTM parameters with `medium=cpc`. |
| **Organic Search** | Visitors who found you through a Google, Bing, or other search engine result — without you paying for the click. |
| **Direct** | Visitors who typed your URL directly into their browser, used a bookmark, or came from a source that could not be identified. |
| **Referral** | Visitors who clicked a link to your site from another website (a blog post, directory listing, partner site, etc.). |
| **Email** | Visitors from email campaigns. Requires UTM parameters with `medium=email` on your email links. |
| **Display** | Visitors from banner ads or display advertising networks. |

<Callout type="info" title="Donut chart visitor count">
The Traffic Sources donut chart counts visitors using session-level data, which includes both web visitors and server-side events. This number may differ slightly from the KPI card visitor count, which only counts unique web visitors.
</Callout>

## Breakdown boards

The dashboard includes four breakdown boards that let you analyze your traffic from different angles. Each board has multiple tabs for deeper analysis.

### Traffic Sources board

| Tab | Shows |
|-----|-------|
| **Channel** | Visitors grouped by channel (Paid Social, Direct, Organic Search, etc.) |
| **Referrer** | The specific websites or domains sending visitors to you |
| **Campaign** | Traffic grouped by UTM campaign name |
| **Keyword** | Search keywords driving traffic (requires Google Search Console connection) |

### Geography board

| Tab | Shows |
|-----|-------|
| **Country** | Where your visitors are located by country |
| **Region** | State or province level breakdown |
| **City** | City-level breakdown |

### Content board

| Tab | Shows |
|-----|-------|
| **Page** | Which pages get the most views |
| **Entry Page** | The first page visitors land on |
| **Exit Page** | The last page visitors view before leaving |

### Technology board

| Tab | Shows |
|-----|-------|
| **Browser** | Chrome, Safari, Firefox, etc. |
| **OS** | Windows, macOS, iOS, Android, etc. |
| **Device** | Desktop, Mobile, or Tablet |

## Metrics in each breakdown

Every row in a breakdown board shows these metrics:

- **Visitors** — unique people
- **Visits** — total sessions (one person can have multiple visits)
- **Pageviews** — total pages viewed
- **Bounce Rate** — percentage of visits where the person left after one page
- **Conversions** — number of attributed conversions
- **Revenue** — attributed cash revenue

Click any row to expand a detail dialog showing the full metric breakdown and trend over time. You can also change the sort order to rank by any of these metrics.

## Filtering

Use dashboard filters to focus on specific traffic segments. For example, filter to **Channel = Paid Social** to see only visitors from your paid ads, then examine which countries or devices they use.

Filters apply across all four boards simultaneously, so you can combine dimensions — like seeing only mobile visitors from the United States who came through Paid Social.

<Callout type="info" title="Channel classification details">
For a technical deep-dive on how Atribu classifies channels (including click ID detection and UTM rules), see [Channels](/docs/concepts/channels).
</Callout>

## Related

<Cards>
  <Card title="UTM Parameters" href="/docs/tracking/utm-parameters">
    Tag your traffic sources for accurate channel classification
  </Card>
  <Card title="Dashboard" href="/docs/features/dashboard">
    See traffic source data alongside KPIs and campaign performance
  </Card>
</Cards>

---

# Ads Performance

The Ads page shows every campaign, ad set, and ad from your connected ad platforms, ranked by performance. This is where you find out which ads are making money and which are wasting budget.

<VideoEmbed src="/docs/videos/ads-performance.mp4" title="Ads Performance — rank campaigns by what matters" />

## Ad hierarchy

Ad platforms organize ads in a three-level hierarchy. Use the level toggle at the top of the page to switch between them:

- **Campaign** — the top level. Groups ad sets under a shared objective and budget.
- **Ad Set** — the middle level. Controls targeting, placement, and schedule.
- **Ad** — the individual creative. The image, video, or text that people actually see.

Each level shows aggregated metrics. A campaign's spend is the sum of all its ad sets; an ad set's clicks are the sum of all its ads.

## Key metrics explained

| Metric | What it means | Formula |
|--------|--------------|---------|
| **Spend** | Total money spent on this campaign, ad set, or ad. | Sum of daily spend |
| **Impressions** | How many times your ad was shown to people. | Sum of daily impressions |
| **Clicks** | How many people clicked your ad to visit your site. | Sum of daily clicks |
| **CTR** | Click-Through Rate — what percentage of people who saw your ad clicked it. Higher is better. | Clicks / Impressions x 100 |
| **CPM** | Cost Per Mille — how much it costs to show your ad 1,000 times. | Spend / Impressions x 1,000 |
| **CPC** | Cost Per Click — how much each click costs you. | Spend / Clicks |
| **Reach** | Number of unique people who saw your ad (one person counted once). | Sum of daily reach |
| **Frequency** | Average number of times each person saw your ad. | Impressions / Reach |
| **ROAS** | Return on Ad Spend — how much revenue each dollar of ad spend generates. | Revenue / Spend |
| **CAC** | Customer Acquisition Cost — how much it costs to acquire one conversion. | Spend / Conversions |

<Callout type="info" title="Real-time computation">
All derived metrics (CTR, CPM, CPC, ROAS, CAC) are computed in real-time from raw data. They are never stored as stale values, so they always reflect the latest numbers including any currency normalization.
</Callout>

## Video metrics

For video ads, additional engagement metrics are available:

| Metric | What it means |
|--------|--------------|
| **Hook Rate** | Percentage of impressions that started watching the video. A high hook rate means the first frame grabs attention. |
| **Retention Rate** | Percentage of viewers who watched at least 75% of the video. Shows how engaging the content is. |
| **Completion Rate** | Percentage of viewers who watched the entire video. |
| **Thumb Stop Ratio** | Percentage of unique people reached who stopped scrolling to watch. |
| **Avg Watch Time** | Average time viewers spent watching the video. |

## Daily trend chart

Click on any campaign, ad set, or ad to see its performance over time. The daily trend chart shows spend, impressions, and clicks plotted day by day, making it easy to spot patterns, identify when performance changed, or see the impact of creative refreshes.

## Attribution model switching

The same ad can show very different ROAS numbers under different attribution models. Use the model selector to compare:

- **Last Touch** gives all credit to the last ad the customer interacted with before converting
- **First Touch** gives all credit to the first ad that introduced the customer
- **Linear** splits credit equally across all touchpoints
- **Time Decay** gives more credit to touchpoints closer to the conversion
- **Last Non-Direct** ignores Direct visits so a later ad click still gets credit over a customer just typing your URL

Switching models helps you understand whether an ad is better at introducing new customers (strong first-touch ROAS) or closing sales (strong last-touch ROAS).

<Callout type="info" title="ROAS only counts real payments">
ROAS calculations only include revenue from confirmed payments (Stripe/MercadoPago). Pipeline values from CRM deals are never included, because they represent projections rather than cash in hand.
</Callout>

## Entity detail page

Click on any campaign, ad set, or ad name to open its detail page. The detail page shows:

- All metrics in card format with the exact numbers
- The daily trend chart for that specific entity
- For individual ads: the creative thumbnail, body text, and title
- For video ads: the full video player plus video engagement metrics
- Attribution data showing which conversions this entity contributed to

## Related

<Cards>
  <Card title="Attribution Models" href="/docs/concepts/attribution-models">
    How different models change ROAS and campaign rankings
  </Card>
  <Card title="Revenue Types" href="/docs/concepts/revenue-types">
    Why only cash payments appear in ROAS calculations
  </Card>
  <Card title="Attribution Windows" href="/docs/settings/attribution-windows">
    Configure how far back touchpoints can receive credit
  </Card>
</Cards>

---

# Conversion Sync

Conversion Sync sends your real business outcomes back to ad platforms so they can optimize for what actually matters — revenue, not just clicks.

Most attribution tools stop at reporting. Conversion Sync closes the loop: when a customer pays, books an appointment, or becomes a qualified lead, Atribu tells Meta and Google about it so their algorithms learn which audiences and creatives drive real results.

## Why this matters

Ad platforms optimize based on the signals they receive. If you only send page views and form submissions, their algorithms optimize for volume. If you send actual payment events with real revenue amounts, they optimize for customers who spend money.

Conversion Sync is not a generic event forwarder. It is a **quality-gated feedback system** that only sends high-confidence events with verified identity context. Weak signals get filtered out before they reach the platform.

## How it works

```mermaid
flowchart LR
    A[Customer pays / books / converts] --> B[Atribu records outcome event]
    B --> C{Signal rules match?}
    C -->|Yes| D{Quality gates pass?}
    C -->|No| E[Event stays internal only]
    D -->|Yes| F{Privacy filters applied}
    D -->|No| G[Skipped — logged with reason]
    F --> H[Meta CAPI / Google Ads]
```

1. **A conversion happens** — a payment, lead submission, appointment, or pipeline change is recorded in Atribu
2. **Signal rules evaluate** — your configured rules determine which events should be sent and where
3. **Quality gates filter** — events older than the platform's staleness limit are dropped, duplicate copies of the same booking that arrived through more than one path (tracker + CRM) collapse into one canonical event, and only events with sufficient identity context (email, phone, click IDs) pass through. In [Healthcare Mode](/docs/healthcare/overview), the export allowlist is checked first
4. **Privacy filters scrub** — PII is hashed, sensitive fields stripped, URLs sanitized
5. **Platform receives the signal** — Meta CAPI or Google Ads gets a clean, high-confidence conversion event

Events that don't pass are logged with a clear reason so you always know what was sent and what was filtered.

## Getting started

When you first visit the Conversion Sync page, a guided setup walks you through three steps:

<Steps>

<Step>
### Connect your destinations

Link at least one ad platform (Meta or Google Ads) in Settings > Integrations. Conversion Sync uses these existing connections — you don't need to set up new OAuth flows.
</Step>

<Step>
### Create a signal rule

A signal rule defines: what event triggers a signal, where it goes, and what value to attach. Click **New Signal** to open the rule builder.
</Step>

<Step>
### Activate and monitor

Once your rule is saved and a destination is configured, signals start flowing. Monitor delivery in the Deliveries tab.
</Step>

</Steps>

## Creating signal rules

The rule builder is a 4-step wizard.

### Step 1: Source — what triggers the signal

Choose what kind of event should create a signal:

| Source type | When to use | Example |
|------------|-------------|---------|
| **Tracked event** | An outcome from your website, payment provider, or CRM | Payment received, lead created, order placed |
| **Pipeline stage change** | A customer moves to a new stage in your CRM pipeline | Appointment booked, qualified, closed won |

For tracked events, select from your [conversion definitions](/docs/settings/goals). For pipeline changes, select the target stage and optionally filter by the previous stage. Pipeline rules can also trigger when a customer exits a stage, and can be filtered by the source system that reported the change.

**Value mode** controls the monetary value sent with the signal:

| Mode | Behavior |
|------|----------|
| **Use actual amount** | Sends the real payment/deal value from the event |
| **Fixed amount** | Sends a fixed value you choose (e.g., $50 per lead) |
| **No value** | No monetary value attached |

Value modes apply to tracked events. Pipeline stage rules are always sent without a value — they are non-revenue optimization signals.

### Step 2: Destinations — where to send

Toggle Meta and/or Google for each rule. You can optionally override the event name sent to each platform (e.g., send "Purchase" to Meta and "purchase" to Google). For Meta you can also choose the optimization category Atribu uses when it auto-creates a Custom Conversion, and preview the live CAPI payload. The Google event name is a display label only — the actual payload is keyed to the destination's conversion action.

A single rule can send to both platforms simultaneously. Each platform independently evaluates whether it has enough match context — a payment from a Meta ad click goes to Meta, a payment from a Google ad click goes to Google, and a payment with both identifiers goes to both.

### Step 3: Privacy overrides

Each rule can override privacy settings on top of the profile-level defaults — strip additional fields or block specific URL paths for this rule only. In standard mode the rule-level override takes precedence over the profile settings, so it can tighten or relax them for this rule; review it carefully.

In [Healthcare Mode](/docs/healthcare/overview), the HIPAA privacy floor is enforced — overrides can only add restrictions, never weaken them — and this step shows all toggles locked.

### Step 4: Review and save

Confirm your settings and save. The rule begins evaluating events immediately. You can edit or archive rules at any time.

## Configuring destinations

The Destinations tab is where you configure the technical details for each ad platform.

### Meta CAPI

| Setting | Description |
|---------|-------------|
| **Connection** | Select your Meta Business Account (connected in Settings) |
| **Pixel** | Pick a Pixel by name from the connection's list (auto-selected when the account has only one). Alternatively, use **Connect a data source** to wire the destination to a WhatsApp, Messenger, or Instagram dataset |
| **Quality preset** | Controls how strict the identity matching requirements are |
| **Test send** | Send a test event — **Website** or **WhatsApp (CTWA)** — to verify delivery. Test sends bypass the quality gates and are rate-limited to 5 per minute |

**Quality presets** control which events are sent:

| Preset | Requires | Best for |
|--------|----------|----------|
| **Lenient** | Email/phone OR Meta click ID | Maximum signal volume, lower match rates |
| **Balanced** (default) | Email or phone (hashed). Meta identifiers are attached when available but not required | Recommended for most accounts |
| **Strict** | Both email/phone AND Meta click ID | Highest match confidence, fewer signals |

"Meta click ID" means `fbc` (from an ad click) or `ctwa_clid` (from a Click-to-WhatsApp ad) — the `fbp` browser cookie does not count. On top of the preset, each destination has optional hard gates — **Require click ID**, **Require email**, **Require phone** — that drop any event missing that signal, regardless of preset.

### Google Ads

| Setting | Description |
|---------|-------------|
| **Connection** | Select your Google Ads account (connected in Settings) |
| **Conversion action** | The Google Ads conversion action path |
| **Quality preset** | Balanced only — requires a `gclid` for every event |

Google Ads uses a fixed balanced quality preset because Google's Enhanced Conversions require a Google Click ID (`gclid`) for reliable attribution.

## Click-to-WhatsApp conversions

If you run Click-to-WhatsApp (CTWA) ads, Conversion Sync exports the outcomes of those conversations back to Meta automatically — no extra rule configuration needed.

1. **Capture** — when someone messages you from a CTWA ad, the first message carries a click ID (`ctwa_clid`). Atribu stores it on the customer profile and the conversation.
2. **Automatic upgrade** — any matched signal that carries a `ctwa_clid` (the event's own `click_ids.ctwa_clid`, else the customer's stored one) is sent to the dataset linked to your WhatsApp Business Account as a business-messaging event: the `action_source` becomes `business_messaging` with WhatsApp as the messaging channel, and the payload carries the `ctwa_clid` and your WhatsApp Business Account ID alongside hashed email/phone. An outcome your system declares with another `action_source` (for example `physical_store`) keeps its website destination — see [Where the outcome happened](#where-the-outcome-happened-action_source).
3. **Event names** — business-messaging events use Meta's messaging event names unless the rule sets an explicit override:

| Outcome | Messaging event name |
|---------|---------------------|
| Lead created | `LeadSubmitted` |
| Order placed | `OrderCreated` |
| Payment received / closed won | `Purchase` |
| Appointment booked | `Schedule` |
| Checkout started | `InitiateCheckout` |
| Add to cart | `AddToCart` |
| Content viewed | `ViewContent` |

A few things to know:

- **One event per click** — Meta accepts at most one conversion per `ctwa_clid`. Atribu enforces this against previously sent events and within each batch; extra events are skipped with the reasons `whatsapp_ctwa_max_events_reached` and `whatsapp_ctwa_duplicate_in_batch`.
- **Quality gates** — `ctwa_clid` counts as the Meta click ID: lenient passes on it alone, and strict accepts it in place of `fbc`.
- **Datasets** — Meta allows one dataset per WhatsApp Business Account, and it is what Meta attributes `ctwa_clid` conversions against. Atribu creates (or fetches) it through `POST /{waba_id}/dataset` as soon as both WhatsApp and Meta Ads are connected, and sends it only click-to-WhatsApp conversions, with the WhatsApp token. The routing follows the WhatsApp connection: while it is disconnected, those conversions go to the website Pixel instead, and it also applies to Platform-Safe profiles (whose website traffic goes to the Clean Dataset). **Connect a data source** on the Meta destination wires Messenger and Instagram datasets the same way.
- **Relay automatic events** — a per-destination toggle that additionally relays Meta's own NLP-detected WhatsApp events (leads, purchases) to CAPI, with no signal rule needed. Not available in the EU, UK, or Japan.
- **Testing** — the **WhatsApp (CTWA)** test button sends the production payload shape with a sample click ID.

## Where the outcome happened (`action_source`)

Meta asks every Conversions API event where the conversion happened (`action_source`). Atribu sends the truth whenever it knows it:

- **Declared by your system.** An outcome posted to [`POST /api/v1/events`](/docs/api/partners#which-action_source-to-send) with `action_source` is sent with exactly that value, whatever the privacy mode. Any value other than `website` is sent **without `event_source_url`** — not even the domain — even when the buyer has browser click signals (`fbc`/`fbp`), which still count as match keys. A sale taken at a counter or recorded in a CRM is not a website event, and for a health business the domain in the URL is what Meta uses to categorize the data source.
- **Not declared.** Atribu infers it as before: `business_messaging` for a click-to-WhatsApp conversion on a WhatsApp dataset, `website` when the event has browser signals, `system_generated` otherwise.
- **`business_messaging` without a WhatsApp dataset or a click ID** is sent as `chat`. Meta rejects a `business_messaging` event that carries no messaging click ID (and the whole batch with it); `chat` is Meta's "conversion made via a messaging app", and it is also sent without a URL.

The Deliveries feed shows the source each event carried (`action_source` on the ledger), and the event detail's filters list `action_source_declared:<value>` and `event_source_url_omitted_non_website` when they applied.

## Monitoring deliveries

The Deliveries tab shows a feed of every signal event — sent, skipped, failed, or queued. Use **Refresh feed** to pull the latest.

### Filter and search

Filter the feed by:
- **Platform** — All, Meta, or Google
- **Status** — Eligible, Sent, Skipped, Failed, or Dead Letter
- **Rule** — Show events for a specific signal rule

### Event detail

Click any row to see the full detail:
- **Status and reason** — Why the event was sent, skipped, or failed
- **Quality signals** — Which identity fields were present (email, phone, click IDs)
- **Privacy filters applied** — What was stripped before sending (IP, UA, referrer, etc.)
- **Customer context** — Campaign name, channel, lead quality score
- **Payload** — The actual data sent to the platform

### Retry and replay

Failed sends retry automatically — up to 4 attempts with exponential backoff (1, 2, 4… minutes, capped at 30 minutes). Events that exhaust all attempts move to Dead Letter status.

Failed, skipped, and dead-lettered events can be replayed individually or in bulk. Replay re-evaluates events against your current rules and settings — useful after updating a destination configuration or fixing a connection issue.

## Understanding skip reasons

When an event is filtered out, the feed shows a clear reason. Here are the most common:

| Skip reason | What it means | What to do |
|-------------|---------------|------------|
| **Missing Meta match data** | No email, phone, or Meta identifiers available | Ensure your forms call [identify()](/docs/tracking/identify-users) before submission |
| **Missing Google click ID** | No `gclid` found for this customer | Add UTM parameters to your Google Ads — see [UTM setup](/docs/tracking/utm-parameters) |
| **Stage transition missing identity** | Pipeline event not linked to a customer profile | Ensure GHL contacts have email/phone |
| **Stage transition missing platform match data** | The pipeline event's customer has no Meta identifiers (`fbc`, `fbp`, or `ctwa_clid`) | Install the tracker on your landing pages so click IDs get captured |
| **Stage transition low confidence** | Pipeline event passed identity and platform matching but failed the destination's quality preset | Improve email/phone capture or relax the preset |
| **Closed won no payment** | Deal marked as won but no confirmed payment found | Connect Stripe or MercadoPago for payment verification |
| **Platform source mismatch** | Event came from the other platform's ad | By design — Meta events go to Meta, Google events go to Google |
| **Duplicate of canonical source** | The same booking arrived through more than one path (tracker + CRM); only the canonical copy is exported | Nothing — this is cross-path dedup working as intended |
| **Blocked path** | The event's page URL matches a blocked path in your privacy settings | Adjust the blocked paths list if unintended |
| **Healthcare allowlist blocked** | Event type not on the [HIPAA export allowlist](/docs/healthcare/export-allowlist) | Add to the allowlist or keep it blocked |
| **Legal gate blocked** | [Healthcare Mode](/docs/healthcare/overview) legal requirements not met | Complete the DPA/BAA acceptance |

Configuration problems (a missing pixel, a disconnected account, a missing conversion action, a disabled destination) surface as their own reasons naming the missing piece — fix the destination configuration and replay. WhatsApp-specific reasons are covered in the Click-to-WhatsApp conversions section above.

## Privacy and compliance

Conversion Sync includes a full privacy layer that controls what data leaves Atribu.

### Privacy modes

| Mode | Behavior |
|------|----------|
| **Standard** | Configurable field stripping with blocklists for URLs and parameters |
| **HIPAA** | Allowlist-only export — only approved event types are sent, all PII stripped, Meta LDU enabled. See [Healthcare Mode](/docs/healthcare/overview) |
| **Custom** | Behaves like Standard — same configurable stripping, URL modes, and blocklists. Use it to mark a deliberately hand-tuned setup |
| **Platform-safe** | Keeps the match identities (hashed email/phone, `fbc`, IP, user agent; never the `fbp` browser id) and removes the context: no page URL, only allowlisted `custom_data`, neutral event names. See [Platform-safe](/docs/api/partners#platform-safe-for-sensitive-verticals-306-1669) |

In HIPAA mode, the privacy panel switches from blocklist fields to [allowlist checkboxes](/docs/healthcare/export-allowlist). The two approaches are mutually exclusive — you cannot have both.

### What gets stripped

In all modes, you can configure which fields are removed before sending:

| Field | Standard (default) | HIPAA |
|-------|-------------------|-------|
| Client IP | Optional | Always stripped |
| User agent | Optional | Always stripped |
| HTTP referrer | Optional | Always stripped |
| Page title | Optional | Always stripped |
| External IDs (fbclid, gclid) | Optional | Always stripped |
| URL | Full URL, origin only, first segment, allowlist, or not sent | Not sent (locked; also in Platform-safe) |
| Meta LDU flags | Optional | Always enabled |

**Platform-safe and HIPAA send no page URL** (#2223): no `event_source_url`, not even the domain, on every event, website events included, whatever the profile or a rule says (the setting is locked). Meta categorizes a dataset by the site its data comes from, so a clinic's domain alone can get a dataset restricted. Meta lists `event_source_url` as required for website events but accepts events without it; matching then relies on the hashed customer data (`user_data`) and the click identifier `fbc` (Platform-safe never sends `fbp`; HIPAA also strips `fbc`, so it matches on the hashed contact data alone).

### Legal compliance

Healthcare accounts must complete a legal workflow before any signals are exported:

1. Accept the [DPA and HIPAA BAA](/dpa) via click-wrap
2. Confirm compliance acknowledgements
3. Upload supporting legal documents (optional)

The legal gate blocks all exports until these requirements are met. See [Healthcare Onboarding](/docs/healthcare/onboarding) for the full setup guide.

## Change log

The Change Log tab records every action taken in Conversion Sync:

- Signal rules created, updated, or archived
- Destinations configured, enabled, or disabled
- Privacy settings changed
- Legal compliance updates
- Test sends and replay actions

Each entry shows when the change happened and what specifically changed.

## Platform differences

Meta and Google handle conversion data differently. Conversion Sync accounts for these differences:

| Aspect | Meta CAPI | Google Ads |
|--------|-----------|------------|
| Identity matching | Hashed email + phone + Meta identifiers (fbp, fbc, ctwa_clid) | `gclid` + hashed email/phone (Enhanced Conversions) |
| Quality presets | Lenient, Balanced (default), Strict | Balanced only (locked) |
| Event name | Customizable (Purchase, Lead, Schedule, etc.) | Fixed by the conversion action — the label is display-only |
| Test sends | Supported (Website + WhatsApp CTWA) | Coming soon |
| Staleness limit | 7 days | 90 days |
| Stage transitions | Supported with quality gates | Supported with quality gates |
| Value currency | Inherited from event or fixed | Inherited from event or fixed |

## Related

- [Install the tracker](/docs/getting-started/install-tracker) — required for web-based identity stitching
- [Connect Meta Ads](/docs/integrations/meta) — set up the Meta connection used by Conversion Sync
- [Connect Google Ads](/docs/integrations/google-ads) — set up the Google Ads connection
- [Goals (Conversion Definitions)](/docs/settings/goals) — define which business outcomes Conversion Sync can use
- [Attribution Models](/docs/concepts/attribution-models) — how Atribu distributes conversion credit
- [Healthcare Mode](/docs/healthcare/overview) — HIPAA-compliant export controls
- [Identity Resolution](/docs/concepts/identity-resolution) — how anonymous visitors become known customers

---

# Customer Journeys

Most customers do not buy on the first visit. They see an ad, browse your site, leave, come back from a Google search, and maybe pay a week later after clicking an email link. The Customer Journeys feature shows you this complete path for every single customer.

<VideoEmbed src="/docs/videos/customer-journey.mp4" title="Customer journey — from ad click to payment" />

## Goal selector

Start by choosing which conversion goal you want to analyze. Common goals include:

- **payment_received** — customers who made a payment (Stripe or MercadoPago)
- **lead_created** — contacts who became leads in your CRM
- **appointment_booked** — people who scheduled an appointment
- **closed_won** — deals that closed successfully

The goal selector shows all conversion types defined for your profile, along with how many conversions occurred in the selected date range.

## Customer list

After selecting a goal, you see a list of every customer who completed that conversion. Each row shows:

| Field | What it shows |
|-------|--------------|
| **Name & email** | The customer's name and email address (if identified) |
| **Channel & source** | How they first found you (e.g., Paid Social from Instagram) |
| **Revenue** | The cash amount from this conversion |
| **Time to convert** | How long it took from their first marketing touch to the conversion |
| **Touchpoints** | Number of marketing interactions before converting |
| **Touch channels** | The sequence of channels in their journey (e.g., Paid Social, Direct, Organic Search) |

You can search for specific customers by name or email, and the list paginates automatically as you scroll.

## Customer detail timeline

Click on any customer to see their full journey timeline. This shows every single interaction in chronological order:

- **Page views** — every page they visited, with URL and referrer
- **Form fills** — when they submitted a form (which triggered identification)
- **Bookings** — appointment scheduling events
- **Payments** — Stripe or MercadoPago transactions with amounts
- **CRM events** — lead status changes, opportunity updates from GoHighLevel

Each event in the timeline shows:

- Timestamp
- Channel and source
- Campaign name (if from a paid ad)
- Device type, browser, and country
- Event-specific details (page URL, payment amount, etc.)

<Callout type="info" title="Synthetic touches">
Some events are marked as "synthetic" — these represent off-site conversions like Meta lead forms where the customer interacted with your ad without visiting your website. Atribu creates these touchpoints automatically so they can be attributed to the correct campaign.
</Callout>

## Why multi-touch journeys matter

Looking at individual customer journeys reveals patterns that aggregate data cannot:

- **How many touches does it take?** If most customers need 4-5 interactions before paying, you know not to judge a campaign by its first-click performance alone.
- **Which channels play which role?** Paid Social might introduce customers (first touch), while Email closes the deal (last touch). Both deserve credit.
- **Where do people drop off?** If customers visit 3 times but never convert, you may need a better offer or retargeting strategy.
- **How long is the buying cycle?** A 2-day cycle means aggressive retargeting works. A 30-day cycle means nurture sequences are essential.

## Users tab

Switch to the Users tab to see all visitors — not just those who converted. This view shows:

- Both identified customers (with names and emails) and anonymous visitors
- Session count, total pageviews, and revenue per visitor
- Last seen timestamp
- The channels they came through

<Callout type="info" title="Anonymous visitor names">
Visitors who have not identified themselves show up with colorful animal names like "Coral Fox" or "Jade Penguin." The same visitor always gets the same name, so you can track them across sessions even before they provide their email.
</Callout>

## Related

<Cards>
  <Card title="Identity Resolution" href="/docs/concepts/identity-resolution">
    How anonymous visitors become known customers
  </Card>
  <Card title="Attribution Models" href="/docs/concepts/attribution-models">
    How credit is distributed across touchpoints in the journey
  </Card>
  <Card title="Synthetic Touches" href="/docs/concepts/synthetic-touches">
    How off-site conversions appear in customer timelines
  </Card>
</Cards>

---

# Reports

Reports let you create professional, shareable marketing performance summaries. They are ideal for client reporting, team updates, or monthly performance reviews.

## What is included in a report

Each generated report contains:

- **KPI summary** — spend, revenue, ROAS, visitors, and conversions for the period
- **Top campaigns** — your best-performing campaigns ranked by attributed revenue and ROAS
- **Channel breakdown** — traffic sources and how each channel contributed to results
- **Attribution analysis** — how credit is distributed across touchpoints under your chosen model
- **Conversion overview** — counts and values for each conversion type

## Creating a report

<Steps>
<Step>

### Go to the Reports page

Navigate to **Reports** in the sidebar. You will see any previously generated reports listed here.

</Step>
<Step>

### Click Generate Report

Click the **Generate** button in the top right corner to open the report configuration dialog.

</Step>
<Step>

### Select your date range

Choose a preset (Last 7 days, Last 14 days, Last 30 days) or pick a custom date range using the calendar.

</Step>
<Step>

### Choose an attribution model

Select which attribution model the report should use for revenue and ROAS calculations. The model you choose affects how credit is distributed across campaigns.

</Step>
<Step>

### Generate and review

Click **Generate** and wait a few seconds. Atribu pulls all your data, runs the calculations, and creates the report. Once ready, you are taken to the report detail page.

</Step>
</Steps>

## Viewing a report

Each report has its own detail page showing the full content. You can scroll through all sections — KPIs, campaign rankings, channel analysis — in a clean, print-ready format.

## Sharing reports

Every report gets a shareable public link. Anyone with the link can view the report without needing an Atribu account.

<Steps>
<Step>

### Copy the share link

On the report detail page, click the **Share** button. The public URL is copied to your clipboard.

</Step>
<Step>

### Send to your client or team

Paste the link in an email, Slack message, or anywhere else. The recipient sees a read-only version of the report.

</Step>
</Steps>

## Downloading as PDF

Click the **Download** button on any report to export it as a PDF file. The PDF preserves the report layout and can be attached to emails or printed.

## Managing reports

- Reports are listed on the Reports page with their date range, model, and creation date
- Click any report to view its full content
- Delete reports you no longer need using the trash icon

<Callout type="info" title="Reports reflect a point in time">
Reports capture your data at the moment they are generated. If new conversions come in or attribution data changes after generation, the report will not update automatically. Generate a new report to get the latest numbers.
</Callout>

## Related

<Cards>
  <Card title="Dashboard" href="/docs/features/dashboard">
    The live view that reports are generated from
  </Card>
  <Card title="Attribution Models" href="/docs/concepts/attribution-models">
    How the model you choose affects report numbers
  </Card>
</Cards>

---

# Calendar

The Calendar page shows your appointments, bookings, and conversion events on a visual timeline. When GoHighLevel is connected, it merges live CRM data with Atribu's attribution data so you can see which campaigns drove each appointment.

## What it shows

The calendar displays events from multiple sources, merged and deduplicated:

| Source | What it provides |
|--------|-----------------|
| **GHL live events** | Real-time appointment status, assigned rep, calendar name |
| **Tracked events** | Attribution data — which campaign, channel, and ad drove this appointment |
| **Merged events** | Both sources combined, showing live status alongside attribution |

When a GHL appointment matches a tracked conversion event in Atribu's database, the calendar merges them into a single entry with the best data from both sides.

## Two views

<Tabs items={["Grid view", "Agenda view"]}>
<Tab value="Grid view">

The grid view shows a weekly calendar layout. Events appear as blocks on their scheduled day and time. Overlapping events are arranged side by side.

The time range adjusts automatically based on your actual appointment times — if your earliest appointment is at 9 AM and your latest is at 6 PM, the grid shows that range rather than a full 24-hour day.

</Tab>
<Tab value="Agenda view">

The agenda view shows events as a simple chronological list. Each event displays the time, customer name, status, and attribution information in a compact row format. This view is better for quickly scanning a large number of appointments.

</Tab>
</Tabs>

## Filters

Narrow down which events appear on the calendar:

- **Assigned user** — filter by the team member assigned to the appointment
- **Calendar** — filter by specific GHL calendar (if you have multiple)
- **Source type** — show only GHL live events, only tracked events, or merged events

## Attribution on appointments

Each appointment on the calendar shows which marketing channel and campaign brought the customer. This means you can see at a glance:

- How many appointments came from Paid Social vs. Organic
- Which specific Meta or Google Ads campaign drove each booking
- Whether the customer had previous interactions before scheduling

The attribution data comes from Atribu's identity resolution — the customer's email or phone from the appointment is matched to their earlier website visits and ad clicks.

<Callout type="info" title="GHL not connected?">
If GoHighLevel is not connected, the calendar shows tracked events from Atribu's database only. You still see appointments captured via tracker auto-capture (booking widget detection) and any other tracked conversion events, but without live GHL status or assigned rep information.
</Callout>

## Data sources explained

Events can have different source labels:

- **merged** — both GHL live data and Atribu tracking data are available for this event
- **db_tracked** — the event exists in Atribu's database (from tracker auto-capture or CRM sync) but was not found in the current GHL live query
- **db_fallback** — GHL is unavailable, so the calendar falls back to showing only database events
- **ghl_live** — the event comes from GHL's live API but has no matching tracked event in Atribu

## Related

<Cards>
  <Card title="GoHighLevel Integration" href="/docs/integrations/gohighlevel">
    Connect GHL to sync appointments and pipeline data
  </Card>
  <Card title="Identity Resolution" href="/docs/concepts/identity-resolution">
    How appointment contacts are matched to website visitors
  </Card>
</Cards>

---

# Data Quality

Data quality directly affects attribution accuracy. If visitors are not identified, if click IDs are missing, or if UTM parameters are inconsistent, your ROAS numbers will be incomplete. The Data Quality page helps you monitor and fix these issues.

## Identity profiles overview

The Data Quality page shows a summary of your customer identity profiles:

| Metric | What it means |
|--------|--------------|
| **Total profiles** | Number of customer profiles in your identity graph |
| **Missing email** | Profiles that have no primary email — these are harder to match across touchpoints |
| **Missing phone** | Profiles that have no primary phone number |

Each profile is listed with its source (GoHighLevel, Stripe, MercadoPago, Meta), name, email, phone, and identifiers. You can filter to see only profiles with missing data.

## Identity repair

When Atribu detects that a customer profile is missing an email or phone number, but that information exists in the raw event data (like a GHL opportunity or Stripe charge), it suggests a repair operation.

<Steps>
<Step>

### Scan for issues

The Data Quality page automatically scans your profiles and identifies those with missing contact information that could be backfilled from existing data.

</Step>
<Step>

### Review suggestions

Each suggestion shows the profile, what data is missing, and the suggested value with its source. For example: "Profile John Doe is missing email. Suggested: john@example.com (from GoHighLevel contact)."

</Step>
<Step>

### Preview and apply

Preview the repair operations to see exactly what will change, then apply them in bulk. The repair process backfills missing data without overwriting any existing values.

</Step>
</Steps>

## What affects data quality

Several factors can reduce the accuracy of your attribution data:

### Ad blockers

Some visitors use ad blockers that prevent the Atribu tracker from loading. This means their visits are not recorded and cannot be attributed. **Solution**: Set up a [custom domain](/docs/tracking/custom-domain) for your tracker to avoid most ad blockers.

### Missing identify calls

If visitors browse your site but never submit a form, complete a booking, or otherwise identify themselves, their anonymous visit cannot be linked to a later payment or CRM event. **Solution**: Enable [form auto-capture](/docs/tracking/auto-capture) so identification happens automatically when visitors fill out any form.

### Missing UTM parameters

Traffic from email campaigns, social media posts, or partner links without UTM parameters gets classified as "Direct" — making it impossible to attribute conversions to those sources. **Solution**: Use consistent UTM parameters on all marketing links.

### Cookie expiration

Visitors who return after a long period (browser cookies cleared, different device) will appear as new visitors. **Solution**: This is a natural limitation. The identity graph still matches them if they provide the same email or phone number.

### Incomplete integrations

If your payment provider or CRM is not connected, those conversion events cannot be attributed to ad campaigns. **Solution**: Connect all your data sources.

## How to improve data quality

<Steps>
<Step>

### Install the tracker on all pages

Make sure the Atribu tracker script is on every page of your website, not just landing pages. Visitors often browse multiple pages before converting, and each page view provides attribution data.

</Step>
<Step>

### Enable form auto-capture

Turn on automatic form detection so the tracker captures email and phone from any form submission. This triggers the `identify()` call that bridges anonymous visitors to known customers.

</Step>
<Step>

### Set up a custom domain

Configure a custom tracking domain (like `track.yourdomain.com`) to bypass most ad blockers and increase data collection rates.

</Step>
<Step>

### Use consistent UTM parameters

Add UTMs to all your marketing links — email campaigns, social posts, partner referrals. Without UTMs, Atribu cannot distinguish these sources from direct traffic.

For Meta ads, use this URL parameter template:
```text title="Meta Ads UTM Template"
utm_source=facebook&utm_medium=paid&utm_campaign={{campaign.id}}&utm_content={{ad.id}}&utm_term={{adset.id}}
```

</Step>
<Step>

### Connect all payment providers

Connect both Stripe and MercadoPago (if you use both) so every payment is captured and attributed. Unconnected payment sources create gaps in your revenue data.

</Step>
<Step>

### Connect your CRM

Connect GoHighLevel for complete pipeline visibility. This lets Atribu track leads, appointments, and opportunities — not just final payments.

</Step>
</Steps>

<Callout type="info" title="What is a good attribution coverage?">
80% or higher attribution coverage is good — it means most of your conversions can be traced back to a marketing touchpoint. 90% or higher is excellent. Below 60% usually indicates missing tracker installation or a lack of form auto-capture.
</Callout>

## Related

<Cards>
  <Card title="Install the Tracker" href="/docs/getting-started/install-tracker">
    Set up the tracking script on your website
  </Card>
  <Card title="Identity Resolution" href="/docs/concepts/identity-resolution">
    How anonymous visitors become known customers
  </Card>
  <Card title="Synthetic Touches" href="/docs/concepts/synthetic-touches">
    How off-site conversions are attributed without web visits
  </Card>
</Cards>

---

# Attribution Models

Atribu supports 5 attribution models that determine how conversion credit is distributed across marketing touchpoints. Each model answers a different question about your customer journey.

<VideoEmbed src="/docs/videos/how-attribution-works.mp4" title="How attribution works — all 5 models explained" />

## How attribution works

```mermaid
flowchart LR
    A["Ad Click"] --> B["Page Views"]
    B --> C["Form Fill"]
    C --> D["identify()"]
    D --> E["Customer Profile"]
    E --> F["Payment"]
    F --> G["Attribution Engine"]
    G --> H["Credit to Campaign"]
```

## Models at a glance

<Cards>
<Card title="Last Touch" href="#last-touch">
All credit to the final touchpoint before conversion
</Card>
<Card title="First Touch" href="#first-touch">
All credit to the first touchpoint that started the journey
</Card>
<Card title="Linear" href="#linear">
Equal credit across every touchpoint in the path
</Card>
<Card title="Time Decay" href="#time-decay">
More credit to touches closer to conversion
</Card>
<Card title="Last Non-Direct" href="#last-non-direct">
Ignores Direct visits to find the real driver
</Card>
</Cards>

---

## Last Touch

All credit goes to the **last touchpoint** before conversion.

**Best for:** Understanding what closes deals and drives the final decision.

**API value:** `last_touch`

```
Ad Click -> Email -> Blog Visit -> [Purchase]
  0%          0%        100%
```

---

## First Touch

All credit goes to the **first touchpoint** that started the customer journey.

**Best for:** Understanding what drives initial awareness and brings people in.

**API value:** `first_touch`

```
Ad Click -> Email -> Blog Visit -> [Purchase]
  100%        0%        0%
```

---

## Linear

Credit distributed **equally** across all touchpoints in the conversion path.

**Best for:** Fair representation of every step in the customer journey.

**API value:** `linear`

```
Ad Click -> Email -> Blog Visit -> [Purchase]
  33.3%      33.3%     33.3%
```

---

## Time Decay

More credit to touchpoints **closer to the conversion**. Uses exponential decay from first to last touch.

**Best for:** Short sales cycles where recent interactions matter most.

**API value:** `time_decay`

```
Ad Click -> Email -> Blog Visit -> [Purchase]
  10%         30%       60%
```

---

## Last Non-Direct

All credit to the last touchpoint that **isn't "Direct" traffic**. If a customer clicks an ad, then later types your URL directly, the ad gets credit.

**Best for:** Removing noise from direct visits to identify the real marketing driver.

**API value:** `last_non_direct`

```
Ad Click -> Email -> Direct Visit -> [Purchase]
  0%          100%       0% (ignored)
```

---

## Using models in the API

Pass the `model` parameter to any endpoint that supports attribution:

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="Request"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/overview?date_from=2026-03-01&date_to=2026-03-25&model=time_decay"
```
</Tab>
<Tab value="JavaScript">
```ts title="Request"
const response = await fetch(
  "https://www.atribu.app/api/v1/overview?date_from=2026-03-01&date_to=2026-03-25&model=time_decay",
  {
    headers: {
      Authorization: "Bearer atb_live_YOUR_KEY",
    },
  }
);
const data = await response.json();
```
</Tab>
<Tab value="Python">
```python title="Request"
import requests

resp = requests.get(
    "https://www.atribu.app/api/v1/overview",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={
        "date_from": "2026-03-01",
        "date_to": "2026-03-25",
        "model": "time_decay",
    },
)
data = resp.json()
```
</Tab>
</Tabs>

<Callout type="info" title="Valid model values">
`last_touch` (default) `first_touch` `linear` `time_decay` `last_non_direct`
</Callout>

---

## Attribution windows

Attribution windows define how far back in time a touchpoint can receive credit for a conversion. These are configurable per profile.

| Window | Default | Description |
|--------|---------|-------------|
| **Click window** | 30 days | How long after a click can a conversion be attributed |
| **View-through window** | 24 hours | How long after an ad impression (no click) |
| **First-touch window** | 90 days | Maximum lookback for first-touch attribution |

<Callout type="info" title="Window behavior">
If a customer clicks an ad on March 1 and converts on March 28, the click is within the default 30-day window and receives credit. If they convert on April 5 (35 days later), the click falls outside the window and is excluded from attribution.
</Callout>

---

## Revenue and ROAS

<Callout type="warn" title="ROAS uses cash revenue only">
Only `revenue_type = 'cash'` (confirmed Stripe and MercadoPago payments) is used for ROAS calculations. GHL pipeline values (closed_won, appointment_booked) are tracked separately as **pipeline value** -- they are projections, not confirmed revenue. See [Revenue Types](/docs/concepts/revenue-types) for details.
</Callout>

The `revenue` and `roas` fields in API responses always reflect attributed cash revenue. Pipeline values appear in conversion counts but are never included in ROAS math.

## Related

<Cards>
  <Card title="Attribution Windows" href="/docs/settings/attribution-windows">
    Configure click, view-through, and first-touch lookback windows
  </Card>
  <Card title="Ads Performance" href="/docs/features/ads-performance">
    See how different models change campaign rankings
  </Card>
  <Card title="Customer Journeys" href="/docs/features/customer-journey">
    View the actual touchpoint paths that models evaluate
  </Card>
</Cards>

---

# Channel Classification

Atribu automatically classifies every visit into a marketing channel based on UTM parameters, referrer URL, and click IDs. This classification drives the Traffic Sources breakdown in your dashboard and the channel dimension in API responses.

---

## Default channels

<Cards>
<Card title="Paid Social">
Meta, TikTok, and other social ads detected by click IDs or paid UTM parameters
</Card>
<Card title="Organic Social">
Unpaid traffic from social platforms like Facebook, Instagram, X, LinkedIn
</Card>
<Card title="Paid Search">
Google Ads, Microsoft Ads, and other search ads detected by click IDs or paid UTMs
</Card>
<Card title="Organic Search">
Unpaid search engine traffic from Google, Bing, Yahoo, DuckDuckGo
</Card>
<Card title="Email">
Traffic from email campaigns identified by utm_medium
</Card>
<Card title="Referral">
Inbound traffic from other websites (not search or social)
</Card>
<Card title="Direct">
No referrer, no UTMs, no click IDs -- typed URL or bookmark
</Card>
<Card title="Display">
Banner and display ad traffic identified by utm_medium
</Card>
<Card title="Affiliate">
Affiliate partner traffic identified by utm_medium
</Card>
</Cards>

---

## Classification rules

| Channel | Detection logic |
|---------|----------------|
| **Paid Social** | `fbclid` or `ttclid` present, OR `utm_medium` contains `paid`, `cpc`, or `ppc` with a social source |
| **Organic Social** | Referrer from a social platform (facebook.com, instagram.com, twitter.com, linkedin.com, etc.) without paid indicators |
| **Paid Search** | `gclid` or `msclkid` present, OR `utm_medium` is `cpc`/`ppc` with a search engine source |
| **Organic Search** | Referrer from a search engine (google, bing, yahoo, duckduckgo, baidu, etc.) |
| **Email** | `utm_medium` is `email` |
| **Referral** | Has a referrer that is not a known search engine or social platform |
| **Direct** | No referrer, no UTM parameters, no click IDs |
| **Display** | `utm_medium` is `display`, `banner`, or `cpm` |
| **Affiliate** | `utm_medium` is `affiliate` |

<Callout type="info" title="Classification priority">
Click IDs take precedence over UTM parameters. If a visit has `fbclid` in the URL, it is classified as **Paid Social** regardless of what `utm_medium` says. This prevents misclassification when UTM tags are missing or incorrect.
</Callout>

---

## Click ID detection

Click IDs are automatically appended to URLs by ad platforms. Atribu detects them to identify paid traffic even when UTM parameters are missing.

| Click ID | Platform | Channel |
|----------|----------|---------|
| `fbclid` | Meta (Facebook / Instagram) | Paid Social |
| `gclid` | Google Ads | Paid Search |
| `msclkid` | Microsoft Advertising (Bing) | Paid Search |
| `ttclid` | TikTok Ads | Paid Social |

<Callout type="warn" title="Always use UTMs alongside click IDs">
Click IDs identify the platform but not the specific campaign. For full attribution down to the ad level, always include `utm_campaign`, `utm_source`, `utm_medium`, and `utm_content` in your ad URLs.
</Callout>

---

## Using channels in the API

### Channel breakdown

Get visitor and revenue distribution across all channels:

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="Request"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/breakdowns?date_from=2026-03-01&date_to=2026-03-25&dimensions=channel"
```
</Tab>
<Tab value="JavaScript">
```ts title="Request"
const response = await fetch(
  "https://www.atribu.app/api/v1/breakdowns?date_from=2026-03-01&date_to=2026-03-25&dimensions=channel",
  {
    headers: {
      Authorization: "Bearer atb_live_YOUR_KEY",
    },
  }
);
const data = await response.json();
```
</Tab>
<Tab value="Python">
```python title="Request"
import requests

resp = requests.get(
    "https://www.atribu.app/api/v1/breakdowns",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={
        "date_from": "2026-03-01",
        "date_to": "2026-03-25",
        "dimensions": "channel",
    },
)
data = resp.json()
```
</Tab>
</Tabs>

```json title="Response"
{
  "data": {
    "channel": [
      { "value": "Paid Social", "visitors": 3200, "sessions": 4100, "pageviews": 9800, "bounce_rate": 38.2 },
      { "value": "Organic Search", "visitors": 2800, "sessions": 3400, "pageviews": 8200, "bounce_rate": 45.1 },
      { "value": "Direct", "visitors": 1500, "sessions": 1800, "pageviews": 4200, "bounce_rate": 52.3 }
    ]
  }
}
```

### Filtering by channel

Filter any endpoint to a specific channel using the `filter[channel]` parameter:

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="Request"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/overview?date_from=2026-03-01&date_to=2026-03-25&filter[channel]=is:Paid+Social"
```
</Tab>
<Tab value="JavaScript">
```ts title="Request"
const response = await fetch(
  "https://www.atribu.app/api/v1/overview?" + new URLSearchParams({
    date_from: "2026-03-01",
    date_to: "2026-03-25",
    "filter[channel]": "is:Paid Social",
  }),
  {
    headers: {
      Authorization: "Bearer atb_live_YOUR_KEY",
    },
  }
);
const data = await response.json();
```
</Tab>
<Tab value="Python">
```python title="Request"
import requests

resp = requests.get(
    "https://www.atribu.app/api/v1/overview",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={
        "date_from": "2026-03-01",
        "date_to": "2026-03-25",
        "filter[channel]": "is:Paid Social",
    },
)
data = resp.json()
```
</Tab>
</Tabs>

<Callout type="info" title="Filter syntax">
Channel filters use the `is:` prefix followed by the exact channel name. Channel names are case-sensitive. Use `+` or `%20` for spaces in URLs.
</Callout>

## Related

<Cards>
  <Card title="UTM Parameters" href="/docs/tracking/utm-parameters">
    Tag your links for accurate channel classification
  </Card>
  <Card title="Traffic Sources" href="/docs/features/traffic-sources">
    See channel data in your dashboard breakdown boards
  </Card>
</Cards>

---

# Revenue Types

Atribu tracks three distinct types of monetary value. Each serves a different purpose, and mixing them produces meaningless metrics.

```mermaid
flowchart TD
    A["Stripe / MercadoPago\nPayment"] -->|"revenue_type = cash"| B["Cash Revenue"]
    C["GHL Pipeline\nDeal Value"] -->|"revenue_type = pipeline"| D["Pipeline Value"]
    E["Shopify Order"] -->|"revenue_type = gross"| F["Gross Value"]
    B --> G["ROAS = Cash / Spend"]
    D -.->|"NOT included"| G
    F -.->|"NOT included"| G
```

---

## The three types

<Cards>
<Card title="Cash">
Confirmed payments from Stripe and MercadoPago. The only value used for ROAS.
</Card>
<Card title="Pipeline">
Deal values from GoHighLevel opportunities. Projections, not confirmed revenue.
</Card>
<Card title="Gross">
Order values from e-commerce platforms. May include refunds or cancellations.
</Card>
</Cards>

---

## Cash (for ROAS)

`revenue_type = 'cash'`

Real payments confirmed by Stripe or MercadoPago. This is the **only** value used for ROAS calculations.

| Property | Detail |
|----------|--------|
| **Source** | `payment_received` events from Stripe/MercadoPago webhooks |
| **Reliability** | 100% -- these are actual bank transactions |
| **API field** | `revenue` in `/api/v1/overview` and `/api/v1/campaigns` |
| **Used in ROAS** | Yes |

---

## Pipeline

`revenue_type = 'pipeline'`

Deal values from GoHighLevel opportunities (lead_created, appointment_booked, closed_won). These are **projections**, not confirmed cash.

| Property | Detail |
|----------|--------|
| **Source** | GHL opportunity sync |
| **Reliability** | Variable -- deals can be lost, renegotiated, or never close |
| **API field** | Visible in conversion counts, excluded from `revenue` and `roas` |
| **Used in ROAS** | No |

---

## Gross

`revenue_type = 'gross'`

Order values from e-commerce platforms (e.g., order_placed events). May include orders that are later refunded, cancelled, or never fulfilled.

| Property | Detail |
|----------|--------|
| **Source** | E-commerce platforms (Shopify, etc.) |
| **Reliability** | Medium -- represents intent to pay, not confirmed collection |
| **API field** | Tracked separately from cash revenue |
| **Used in ROAS** | No |

---

## Why this matters

<Callout type="error" title="Never mix pipeline values into ROAS">
If pipeline values were included in ROAS, a single $50,000 deal projection could make a $500 ad campaign show **100x ROAS** -- completely misleading. That deal might never close, get renegotiated to $5,000, or be lost entirely. Only confirmed cash payments produce trustworthy ROAS numbers.
</Callout>

Consider this example:

| Metric | With cash only | With pipeline included |
|--------|---------------|----------------------|
| Ad spend | $2,000 | $2,000 |
| Revenue | $6,000 (3 payments) | $56,000 (3 payments + 1 deal) |
| ROAS | **3.0x** | **28.0x** |
| Accurate? | Yes | No -- $50K deal hasn't closed |

---

## How it appears in the API

### Overview endpoint

The `revenue` and `roas` fields in `/api/v1/overview` always use **cash only**:

```json title="Overview response (revenue = cash only)"
{
  "data": {
    "current": {
      "spend": 4250.00,
      "revenue": 12800.00,
      "roas": 3.01,
      "cash_revenue": 12800.00,
      "cash_payments": 48
    }
  }
}
```

### Campaigns endpoint

Campaign-level `outcome_value` and `roas` also reflect cash revenue only:

```json title="Campaign response"
{
  "data": [
    {
      "campaign_name": "Spring Sale - Conversions",
      "spend": 1800.00,
      "outcome_count": 45,
      "outcome_value": 6200.00,
      "roas": 3.44
    }
  ]
}
```

<Callout type="info" title="Conversion counts include all types">
The `outcome_count` field includes conversions of **all** revenue types (cash, pipeline, and gross). This gives you visibility into total conversion volume. Only the monetary fields (`revenue`, `outcome_value`, `roas`) are restricted to cash.
</Callout>

### Conversions endpoint

The `/api/v1/conversions` endpoint returns individual conversion events with their `revenue_type`, so you can filter and analyze each type independently:

```json title="Conversion with revenue_type"
{
  "conversion_type": "payment_received",
  "revenue_type": "cash",
  "value": 450.00,
  "customer_name": "John Doe"
}
```

```json title="Pipeline conversion"
{
  "conversion_type": "closed_won",
  "revenue_type": "pipeline",
  "value": 15000.00,
  "customer_name": "Acme Corp"
}
```

## Related

<Cards>
  <Card title="Stripe Integration" href="/docs/integrations/stripe">
    How cash payments are tracked from Stripe
  </Card>
  <Card title="GoHighLevel Integration" href="/docs/integrations/gohighlevel">
    How pipeline values are synced from GHL deals
  </Card>
  <Card title="Conversion Goals" href="/docs/settings/goals">
    Configure which events count as conversions and their revenue type
  </Card>
</Cards>

---

# Identity Resolution

Identity resolution is how Atribu connects the dots between an anonymous website visitor and a known customer who pays you.

<VideoEmbed src="/docs/videos/identity-resolution.mp4" title="Identity resolution — from anonymous visitor to known customer" />

```mermaid
flowchart TD
    A["Anonymous Visitor\n(anonymous_id)"] -->|fills form| B["identify(email)"]
    B --> C["Customer Profile"]
    D["Stripe Payment\n(same email)"] --> C
    E["GHL Contact\n(same email)"] --> C
    F["GHL Opportunity\n(same contact)"] --> C
    C --> G["All events linked\nto one person"]
```

## The problem

Your customer's journey is fragmented:

1. They click a Facebook ad on their phone
2. They visit your site, browse pricing, and leave
3. Two days later, they search your brand name on Google from their laptop
4. They fill out your contact form
5. A week later, they pay via Stripe

Without identity resolution, these look like 5 separate events from 3 different people. With it, Atribu knows it's one person — and attributes the payment back to the original Facebook ad.

## How it works

<Steps>
<Step>

### Anonymous visitor arrives

When someone visits your site, Atribu assigns them a **Visitor ID** (called `anonymous_id`). This ID is stored in their browser and stays the same across all their visits on that device.

</Step>
<Step>

### Visitor identifies themselves

When the visitor fills out a form, books an appointment, or logs in, Atribu's `identify()` function fires with their email (or phone). This creates a **customer profile** that links their Visitor ID to their real identity.

</Step>
<Step>

### Server-side events match

When a payment arrives from Stripe with the same email, or a deal is created in GoHighLevel for the same contact, Atribu matches it to the existing customer profile. The full journey — from ad click to payment — is now connected.

</Step>
</Steps>

## The identity graph

Atribu builds an **identity graph** for each customer:

| Identifier Type | Example | Source |
|----------------|---------|--------|
| Email | jane@example.com | Form fill, Stripe payment |
| Phone | +1-555-0123 | Form fill, GHL contact |
| Visitor ID | `abc123-def456` | Browser cookie |
| Stripe Customer ID | `cus_abc123` | Stripe payment |
| GHL Contact ID | `contact_xyz` | GoHighLevel sync |

All identifiers point to the same customer profile. Any future event with any of these identifiers gets linked to the same person.

## Identifier priority

When resolving who a person is, Atribu uses this priority:

1. **Email** (strongest match)
2. **Phone number**
3. **External IDs** (Stripe customer ID, GHL contact ID)

Names are used for display only — never for matching (too many "John Smith"s).

<Callout type="info" title="Safe to call repeatedly">
The identity resolution system uses "COALESCE" logic — calling `identify()` multiple times with new data backfills missing fields without overwriting existing ones. If a customer first gives their email, then later their phone, both are stored.
</Callout>

## Why `identify()` is critical

<Callout type="error" title="Without identify(), attribution breaks">
If a visitor never identifies themselves (no form fill, no booking, no login), their ad click and page views are tracked — but they can never be connected to a Stripe payment or CRM event. The payment shows up as "unattributed."
</Callout>

Make sure every conversion point on your site triggers identification:

- **Forms**: Atribu auto-captures these (no code needed)
- **Booking widgets**: GHL, Calendly, Cal.com auto-captured
- **Custom flows**: Call `window.atribuTracker.identify({ email: "..." })` manually

See [Identify Users](/docs/tracking/identify-users) for implementation details.

## Related

<Cards>
  <Card title="Auto-Capture" href="/docs/tracking/auto-capture">
    How forms and booking widgets automatically trigger identification
  </Card>
  <Card title="Data Quality" href="/docs/features/data-quality">
    Monitor identity coverage and repair missing data
  </Card>
  <Card title="Customer Journeys" href="/docs/features/customer-journey">
    See the full path from anonymous visitor to identified customer
  </Card>
</Cards>

---

# Synthetic Touches

Not every lead visits your website. Some conversions happen entirely off-site — and Atribu still attributes them.

```mermaid
flowchart TD
    A["Meta Lead Form\n(no website visit)"] --> B["GHL Contact Created"]
    B --> C["Outcome Event\n(has campaign data)"]
    C --> D{"Web touches\nexist?"}
    D -->|No| E["Create Synthetic Touch"]
    D -->|Yes| F["Use existing touches"]
    E --> G["Attribution Engine"]
    F --> G
    G --> H["Credit assigned\nto campaign"]
```

## The problem

When someone fills out a **Meta lead form** (a form shown directly on Facebook or Instagram, without visiting your website), the lead goes straight to GoHighLevel. There's no website visit, no page view, no tracker event. But the lead DID come from a specific ad campaign.

Without synthetic touches, these leads would show up as "unattributed" — even though Meta knows exactly which campaign drove them.

<Callout type="warn" title="This affects ~60% of GHL leads">
For businesses using Meta lead ads with GoHighLevel, most leads arrive via native forms. Without synthetic touches, more than half of your leads can't be attributed to their campaigns.
</Callout>

## How synthetic touches work

When Atribu detects a conversion that has campaign data (from GHL's attribution) but no website visit, it creates a **synthetic touch** — an artificial touchpoint that represents the off-site interaction.

<Steps>
<Step>

### Lead fills out Meta form

A potential customer sees your Instagram ad and fills out the lead form directly on Instagram. They never visit your website.

</Step>
<Step>

### GHL receives the lead

GoHighLevel creates a contact and opportunity. The opportunity includes attribution data from Meta: campaign ID, ad set ID, ad ID.

</Step>
<Step>

### Atribu syncs the data

Atribu syncs the GHL opportunity and creates a conversion event. It sees the campaign data but finds no matching web touches.

</Step>
<Step>

### Synthetic touch created

Atribu creates a synthetic touch with the campaign data from GHL. This allows the attribution engine to assign credit to the correct campaign.

</Step>
</Steps>

## Rules

Synthetic touches follow strict rules to avoid double-counting:

- **Only created when there are no existing web touches** for that customer before the conversion
- **Idempotent**: running the process again doesn't create duplicates
- **No session data**: synthetic touches have no session, page views, or device info (the customer never visited the site)
- **Dashboard metrics unaffected**: visitor, session, and pageview counts come from actual web data, not synthetic touches

## Where they appear

In the customer journey timeline, synthetic touches are marked with `is_synthetic: true`. They show the campaign that drove the lead but don't have page/URL data.

<Callout type="info" title="Visibility in the dashboard">
Synthetic touches affect attribution data (ROAS, campaign revenue) but NOT traffic metrics (visitors, sessions, pageviews). Your visitor counts remain accurate — they only count real website visits.
</Callout>

## Impact on attribution

Without synthetic touches, a business running Meta lead ads might see:
- 100 leads in GHL
- Only 40 attributed to campaigns (the ones who visited the website)
- 60 "unattributed" leads

With synthetic touches:
- 100 leads in GHL
- 95+ attributed to campaigns
- Accurate ROAS showing the true return on your Meta ad spend

## Related

<Cards>
  <Card title="GoHighLevel Integration" href="/docs/integrations/gohighlevel">
    How GHL leads carry campaign data for synthetic touches
  </Card>
  <Card title="Identity Resolution" href="/docs/concepts/identity-resolution">
    How customer profiles are created for off-site leads
  </Card>
  <Card title="Data Quality" href="/docs/features/data-quality">
    Monitor attribution coverage to see the impact of synthetic touches
  </Card>
</Cards>

---

# Quickstart

You are an AI agent, a script, or a developer building one. You have no Atribu
account, no workspace and no API key. This is where you start.

The bootstrap is an **OAuth hand-off**, exactly like connecting Gmail or
Cloudflare: you register a client, build an authorization URL, and hand that URL
to your human. They create their account or sign in **inside** that hand-off and
press Approve. You get a token back and you are the user from then on.

<Callout type="info" title="There is no signup API">
Account creation is a human act behind a captcha. Nothing here requires the
human to have an Atribu account beforehand — the URL you hand them works for a
brand-new visitor, who is walked through signup and returned to your consent
screen automatically.
</Callout>

<Steps>
<Step>

## Register a client and build the authorize URL

Dynamic Client Registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)).
Public clients only — there is no client secret, PKCE proves the exchange.

```bash title="1. Register"
curl -sX POST https://www.atribu.app/oauth/mcp/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My Agent",
    "redirect_uris": ["http://127.0.0.1:7788/callback"],
    "token_endpoint_auth_method": "none",
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"],
    "scope": "mcp:read"
  }'
```

```json title="201 Created"
{
  "client_id": "mcpc_9f2c…",
  "client_id_issued_at": 1788500000,
  "client_name": "My Agent",
  "redirect_uris": ["http://127.0.0.1:7788/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "scope": "mcp:read"
}
```

`redirect_uris` must be `https`, or `http` on loopback (`localhost`,
`127.0.0.1`, `[::1]`) for a local tool. They are matched **exactly** at
`/authorize` — no prefix matching, no wildcards.

Now mint a PKCE pair and build the URL:

```python title="2. Authorize URL (PKCE, S256 — plain is refused)"
import base64, hashlib, os, urllib.parse

verifier  = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
state = base64.urlsafe_b64encode(os.urandom(16)).rstrip(b"=").decode()

url = "https://www.atribu.app/oauth/mcp/authorize?" + urllib.parse.urlencode({
    "response_type": "code",
    "client_id": "mcpc_9f2c…",
    "redirect_uri": "http://127.0.0.1:7788/callback",
    "scope": "mcp:read",
    "state": state,
    "code_challenge": challenge,
    "code_challenge_method": "S256",
    "resource": "https://mcp.atribu.app/mcp",
})
print(url)   # keep `verifier` — step 3 needs it
```

Everything on this endpoint is discoverable at
`https://www.atribu.app/.well-known/oauth-authorization-server`
([RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)).

<Callout type="warn" title="Every failure comes back to you, not to a dead page">
Once your `redirect_uri` is matched against the registered set, every error is
an [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1)
redirect back to it with `error` and `error_description` — `access_denied` when
the human presses Deny, `invalid_request` for a missing PKCE challenge,
`invalid_scope`, `invalid_target`, `unsupported_response_type`. Handle `error`
on your callback as a first-class outcome. An unknown `client_id` or an
unregistered `redirect_uri` deliberately renders a page instead: redirecting to
an unvalidated URI is what the spec forbids.
</Callout>

</Step>
<Step>

## Your agent gives the human a URL. They open it, create their account, approve. They come back.

Print the URL and stop. This is the human's step, and it is the only one.

> Open this to connect your Atribu account:
> `https://www.atribu.app/oauth/mcp/authorize?response_type=code&client_id=…`

What they see, whether or not they already have an account:

1. **No account yet** — the page sends them to sign in; an unrecognised email
   goes straight to signup with the pending authorization preserved. They
   confirm their email, and the confirmation link brings them **back to your
   consent screen**, not to a dashboard.
2. **The consent screen** — your `client_name`, the scopes you asked for as
   individual checkboxes, and the host your `redirect_uri` points at. A user
   with no workspace yet is told so plainly: *"You have no workspace yet — your
   agent will create one after you approve."*
3. **Approve** — the browser is redirected to your `redirect_uri` with `code`
   and your `state`.

Verify `state` matches what you sent before touching `code`.

</Step>
<Step>

## Exchange the code for a token

```bash title="POST /oauth/mcp/token"
curl -sX POST https://www.atribu.app/oauth/mcp/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code=THE_CODE_FROM_THE_CALLBACK \
  -d redirect_uri=http://127.0.0.1:7788/callback \
  -d client_id=mcpc_9f2c… \
  -d code_verifier=THE_VERIFIER_FROM_STEP_1
```

```json title="200 OK"
{
  "access_token": "atb_user_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "atb_refresh_…",
  "scope": "mcp:read"
}
```

The access token lives 1 hour; the refresh token lives 60 days and **rotates on
every use** — store the new one each time. Replaying an old refresh token burns
the whole family, which is the reuse detection working.

```bash title="Refreshing"
curl -sX POST https://www.atribu.app/oauth/mcp/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=refresh_token \
  -d refresh_token=atb_refresh_… \
  -d client_id=mcpc_9f2c…
```

</Step>
<Step>

## Make your first call

The token is a user credential: it spans every workspace that person belongs
to, including ones created after they approved.

```bash title="MCP"
curl -sX POST https://mcp.atribu.app/mcp \
  -H "Authorization: Bearer atb_user_…" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"whoami","arguments":{}}}'
```

`whoami` costs zero units and answers with the workspaces, profiles, currency,
PII mode and write-back state the caller actually has — call it first, always,
rather than guessing.

```bash title="REST — with the same token"
curl -H "Authorization: Bearer atb_user_…" \
  "https://api.atribu.app/api/v1/workspaces"
```

**The same token, on both surfaces.** Since #1084 an `atb_user_…` token is a
first-class `/api/v1` principal, so nothing else has to be minted. Three things
about it are worth knowing before your second call:

- **It names a person, not a tenant.** An API key *is* one profile; this token
  may reach several, so `?profile_id=<uuid>` is **required** on profile-scoped
  routes — its absence is a `400`, never a guess. `GET /api/v1/workspaces`
  above needs neither, which is why it is the right first call.
- **Its scopes are an intersection**, of what the human granted on the consent
  screen and what their own membership already allows. `mcp:read` maps to
  `analytics:read` + `campaigns:read`; no MCP scope reaches exports, commerce,
  or any messaging surface.
- **`/api/v1/me/**` is deliberately out of reach.** A credential cannot
  administer the class it belongs to, so minting or revoking tokens stays a
  signed-in human's act.

### No workspace yet? Bootstrap one — no browser required

`GET /api/v1/workspaces` above answers an empty list for a brand-new account. The same four
calls take you from there to a live tracking key (#1085):

```bash title="Bootstrap: workspace → profile → tracking key → readiness"
# 1. Create a workspace and become its owner
curl -X POST https://api.atribu.app/api/v1/workspaces \
  -H "Authorization: Bearer atb_user_…" \
  -H "Content-Type: application/json" \
  -d '{"name": "My Agency"}'
# → { "data": { "workspace_id": "...", "name": "My Agency", "created": true, "checkout": null } }

# 2. Create a profile inside it (use the workspace_id from step 1)
curl -X POST https://api.atribu.app/api/v1/profiles \
  -H "Authorization: Bearer atb_user_…" \
  -H "Content-Type: application/json" \
  -d '{"workspace_id": "WORKSPACE_ID", "name": "My Website"}'
# → { "data": { "profile_id": "...", "workspace_id": "...", "created": true } }

# 3. Issue a tracking key for that profile
curl -X POST "https://api.atribu.app/api/v1/tracking/keys?profile_id=PROFILE_ID" \
  -H "Authorization: Bearer atb_user_…"
# → { "data": { "id": "...", "public_key": "trk_live_...", "status": "active", "created": true } }

# 4. Check what is still missing before the profile can attribute anything
curl "https://api.atribu.app/api/v1/profile/readiness?profile_id=PROFILE_ID" \
  -H "Authorization: Bearer atb_user_…"
# → { "data": { "summary": { "done": 1, "total": 12, ... }, "steps": [ ... ] } }
```

<Callout type="info" title="Safe to retry">
`POST /api/v1/workspaces` is idempotent on `(you, name)` for a few minutes: calling it again
with the SAME `name` returns the SAME workspace (`created: false`, `200`) instead of a
duplicate, so a dropped connection never leaves two workspaces behind.
</Callout>

<Callout type="info" title="Starting on a paid plan">
Pass `"plan": "growth"` or `"plan": "agency"` in step 1's body instead of `"starter"` (the
default). The workspace immediately trials at that plan's real limits — nothing is blocked while
a human pays — and the response also carries a real Stripe Checkout hand-off in `checkout`:

```json
{
  "data": {
    "workspace_id": "...",
    "name": "My Agency",
    "created": true,
    "checkout": { "id": "...", "url": "https://api.atribu.app/h/...", "expires_at": "..." }
  }
}
```

Give the human `checkout.url`, then poll `GET /api/v1/handoffs/{checkout.id}` until it settles
(`status: "completed"` names the resulting subscription). A mint that could not run — billing
unconfigured on this deployment, or Stripe refusing the request — never blocks the workspace:
`checkout` comes back `null` with a `warnings` entry naming `checkout_unavailable` instead of
either of those, and you can retry the mint alone with
`POST /api/v1/workspaces/{workspace_id}/checkout-session`.
</Callout>

Full detail: [Authentication](/docs/api/authentication).

</Step>
</Steps>

## Using an API key instead

A key is the right credential for a server-to-server job that belongs to a
**profile** rather than to a person: a nightly export, a CI check, a webhook
consumer. It carries no user identity and no consent screen, and it can never
mint another key.

1. Log in to [Atribu](https://www.atribu.app)
2. Go to **Settings > Developer**
3. Click **Create API Key**
4. Select your scopes and copy the key

<Callout type="warn" title="Save your key">
The key starts with `atb_live_` and is shown only once. Store it somewhere secure.
</Callout>

```bash title="Request"
curl -H "Authorization: Bearer atb_live_YOUR_KEY_HERE" \
  "https://www.atribu.app/api/v1/overview?date_from=2026-03-01&date_to=2026-03-25"
```

```json title="Success response (200 OK)"
{
  "data": {
    "current": {
      "spend": 4250.00,
      "revenue": 12800.00,
      "roas": 3.01,
      "outcomes": 145,
      "attributed_outcomes": 132,
      "coverage_percent": 91.03,
      "visitors": 8420,
      "pageviews": 24100,
      "bounce_rate": 42.5,
      "avg_engaged_seconds": 185,
      "cash_revenue": 12800.00,
      "cash_payments": 48
    },
    "previous": { "..." : "..." }
  },
  "meta": {
    "date_from": "2026-03-01",
    "date_to": "2026-03-25",
    "profile_id": "your-profile-id"
  }
}
```

The full OpenAPI 3.1 document — every route, scope, and response shape — is
served from the API host itself, unauthenticated:

```
https://api.atribu.app/api/v1/openapi.json
```

Point an SDK generator or an AI agent's tool-loader at it directly; no key
required to fetch the spec.

## Next steps

<Cards>
  <Card title="Authentication" href="/docs/api/authentication">
    API key scopes, rate limits, and security best practices
  </Card>
  <Card title="API Reference" href="/docs/api/overview">
    Full endpoint documentation
  </Card>
  <Card title="Attribution Models" href="/docs/concepts/attribution-models">
    How credit is assigned
  </Card>
</Cards>

---

# Authentication

All API requests require a Bearer token in the `Authorization` header.

```bash title="Authorization header"
Authorization: Bearer atb_live_your_key_here
```

## Getting a key

Create API keys from **Settings > Developer** in the Atribu dashboard. Each key is scoped to a single profile.

<Callout type="warn" title="Show once">
Keys are shown exactly once at creation. Store them securely — you cannot retrieve them later.
</Callout>

## Scopes

Each API key has granular scopes that control what data it can access.

{/* BEGIN GENERATED SCOPE TABLES — generated by scripts/generate-scope-docs.ts from KEY_SCOPES (packages/atribu-api-contract/src/oauth-scopes.ts). Do not hand-edit between these markers; run `npx tsx scripts/generate-scope-docs.ts`. */}

### Analytics (read-only)

| Scope | Access | Endpoints |
|-------|--------|--------|
| `analytics:read` | Read aggregated analytics: overview, timeseries, breakdowns, quality, touches, and channel/referrer/page/keyword/country/device/browser/OS dimensions. | 146 |
| `campaigns:read` | Read campaign, ad set and ad performance: ROAS, trends, archetypes, creative patterns, DM ads and top performers. | 38 |
| `conversions:read` | Read conversion counts and revenue, including cash-only revenue and conversion timeseries. Also opens the projected journey reads (`GET /api/v1/conversions/{id}/journey`, `GET /api/v1/customers/journey?customer_key=`) — the marketing path, no person, no pages. | 11 |
| `realtime:read` | Read the live visitor count. | 1 |

### PII (read-only)

<Callout type="error" title="PII scopes">
`customers:read` and `visitors:read` expose personal data (names, emails, journey details). Only grant these when your integration specifically needs customer-level data.
</Callout>

| Scope | Access | Endpoints |
|-------|--------|--------|
| `customers:read` | Read the customer list and per-customer journey detail — personally identifiable information. | 27 |
| `visitors:read` | Read the visitor list — personally identifiable information. Withheld on healthcare-family profiles. | 2 |

### Commerce (read-only)

<Callout type="warn" title="commerce:live carries buyer contact">
`commerce:live` reads a connected Shopify store live, and every order it returns carries the buyer's email and phone. The `commerce` connect scope grants it together with `commerce:read`.
</Callout>

| Scope | Access | Endpoints |
|-------|--------|--------|
| `commerce:read` | Read the merchant's product catalogue (products, variants, stock and prices) and look an order up by number, email or phone. Returns no customer identity. | 2 |
| `commerce:live` | Read a connected Shopify store live — its catalogue with stock, and its orders updated since a time, including the buyer's email and phone — proxied to Shopify, never from Atribu's copy. Personally identifiable information. | 2 |

### Conversion export (read, write)

| Scope | Access | Endpoints |
|-------|--------|--------|
| `exports:read` | Read the conversion-export/signal-sync surface: Meta CAPI wiring diagnostics, dataset quality, Custom Audiences, export deliveries, and the compliance state behind Platform-Safe Mode. | 40 |
| `exports:write` | Configure the conversion-export pipeline: create, edit and disable export destinations (Meta dataset/pixel, Google Ads conversion action) and the signal rules that map a conversion definition to a platform event, and send a Meta test event. | 47 |

### Provisioning (write)

| Scope | Access | Endpoints |
|-------|--------|--------|
| `workspaces:write` | Create a workspace (POST /api/v1/workspaces). Not granted by any OAuth connect scope; an MCP user token holds it through the `mcp:write` grant. | 0 |
| `profiles:write` | Create a profile inside a workspace the caller already belongs to (POST /api/v1/profiles). Not granted by any OAuth connect scope; an MCP user token holds it through the `mcp:write` grant. | 0 |

### Attribution & goals (write)

| Scope | Access | Endpoints |
|-------|--------|--------|
| `events:write` | Ingest server-side outcome events (e.g. a closed sale) via POST /api/v1/events. | 7 |
| `goals:write` | Create, list and edit conversion goal definitions. | 14 |
| `attribution:write` | Trigger an attribution recompute for a profile. | 46 |
| `tracking:write` | Configure a site's tracking installation: issue and revoke tracking keys, change the tracker's session/enrichment settings (which enqueues a full attribution replay), and start an install verification. | 11 |
| `reports:write` | Create, schedule, generate and send attribution reports (report schedules, deliveries and send). | 13 |
| `campaigns:apply` | Apply or dismiss a campaign recommendation, and act on an ad or campaign directly: pause or resume a campaign or an ad, set Atribu's URL parameters on an ad, or change an ad set's daily budget. Not granted by default — a workspace admin must opt a key into it, or the app must hold the `attribution` connect scope. | 10 |
| `creatives:write` | Queue an on-demand AI analysis of one ad's creative (`POST /api/v1/ads/{id}/creative-analysis`). Each accepted call queues a model call against the workspace's daily creative-analysis allowance, so it is capped per profile and per key per day and is not granted by default — a workspace admin must opt a key into it, or the app must hold the `attribution` connect scope. | 5 |

### Connections (read, write)

| Scope | Access | Endpoints |
|-------|--------|--------|
| `connections:read` | Read the state of a profile's connections: the connection list and detail, and the progress of a running sync. | 8 |
| `connections:write` | Run the connect rail: mint a connect hand-off URL, list the candidate accounts behind a parked provider token, finalize the selection, trigger a sync, and disconnect a connection. | 9 |

### Calendar (read, write, manage)

| Scope | Access | Endpoints |
|-------|--------|--------|
| `calendar.read` | Read-only access to Atribu booking calendars and their events. | 5 |
| `calendar` | Create, update and delete events on Atribu booking calendars. | 12 |
| `calendar.manage` | Create, list and share (ACL) Atribu booking calendars. | 8 |

### Messaging (write)

| Scope | Access | Endpoints |
|-------|--------|--------|
| `whatsapp` | Send and manage WhatsApp messages, templates, flows, broadcasts, calling and phone-number registration. | 58 |
| `instagram` | Send and manage Instagram DMs, comment replies, triggers and media. | 47 |
| `messenger` | Send Messenger messages from the connected Facebook Page. | 3 |
| `tiktok_messaging` | Send and read direct messages of the connected TikTok account (TikTok Business Messaging). | 5 |
| `email` | Read and send connected email (Gmail/Outlook): drafts, threads, labels and search. | 8 |

### Messaging (read-only)

| Scope | Access | Endpoints |
|-------|--------|--------|
| `whatsapp:read` | Read WhatsApp conversations, messages, templates and broadcasts. A read-only subset of `whatsapp`, which also allows sending and managing them. | 5 |

### Inbox conversations (read, write)

<Callout type="error" title="PII scopes">
`conversations:read` and `conversations:write` expose personal data — contact names, phone numbers and message content. Only grant these when your integration specifically needs inbox-level data.
</Callout>

| Scope | Access | Endpoints |
|-------|--------|--------|
| `conversations:read` | Read the unified inbox: conversation list and message threads across WhatsApp, Instagram and email, plus organic/ad match suggestions, contact-extraction review proposals and the Instagram DM↔payment reconciliation worklist. | 9 |
| `conversations:write` | Reply on a conversation, save an email draft, apply Gmail labels, link/unlink a conversation to a customer, and approve/reject a contact-extraction review proposal. | 7 |

**Default scopes** when creating a key: `analytics:read`, `campaigns:read`, `conversions:read`, `realtime:read`.

{/* END GENERATED SCOPE TABLES */}

## The MCP user token — one credential for both surfaces

Since #1084 an **MCP user token** (`atb_user_…`) authenticates on `/api/v1` exactly as it does on
`mcp.atribu.app`. It is the credential for a person's own AI agent: one token, both surfaces, no
separate API key to mint per profile.

```bash title="Authorization header"
Authorization: Bearer atb_user_your_token_here
```

### How to get one

<Steps>
<Step>
**Through the MCP OAuth flow** — the bootstrap for an agent with no Atribu account of its own.
The agent registers a client (dynamic registration + PKCE) against `https://mcp.atribu.app`,
builds the authorize URL and hands it to its human. The human signs in, approves the consent
screen, and the agent holds the token. See [MCP authentication](/docs/mcp/authentication).
</Step>
<Step>
**Or mint one directly** if you are already signed in: `POST /api/v1/me/mcp-tokens`, from
**Settings > Developer** or from a session-authenticated call. The plaintext token is returned
once and never again.
</Step>
</Steps>

### How it differs from an API key

An API key **is** a tenant — it is minted for exactly one profile. A user token names a **person**,
who may reach several. So:

- `?profile_id=<uuid>` is **required** on profile-scoped routes. Its absence is a `400`
  `invalid_request`, never a guess. Workspace-scoped routes take `?workspace_id=` (or the path
  segment) instead.
- A profile the person cannot reach answers `404 not_found` — byte-identical to a profile that does
  not exist, so the status line can never be used to enumerate tenants.
- The workspace-grain routes an API key is refused on (`/api/v1/workspaces`, the cross-profile
  leaderboards) are open to a user token.

### What the MCP scopes map to

A request's scopes are the **intersection** of two things: what the token's `mcp:*` grant maps to,
and what the person's own workspace/profile membership already grants. Neither can widen the other.

| MCP scope | API scopes it maps to |
|-----------|-----------------------|
| `mcp:read` | `analytics:read`, `campaigns:read`, `conversions:read`, `realtime:read` |
| `mcp:read_pii` | `customers:read` |
| `mcp:write` | `exports:read`, `exports:write`, `goals:write`, `attribution:write`, `tracking:write`, `reports:write`, `workspaces:write`, `profiles:write`, `campaigns:apply` |

No MCP scope grants `commerce:read`, `exports:read`, `exports:write`, `visitors:read`,
`events:write`, `creatives:write`, or any messaging, calendar or inbox scope. Those need an
API key.

Because the answer is an intersection, a `403 insufficient_scope` can mean either half is too
narrow — the message names both, so you know whether to re-consent with a wider grant or ask a
workspace admin for more access.

### Routes a user token can never reach

Three groups of writes answer `403 insufficient_scope` to a user token **whatever its grant**,
because acting on them would let a credential administer the credential system itself. Widening
the token's scopes does not change the answer; only a signed-in session (or, where it already
worked, HTTP Basic) can do these.

| Route | Why |
|---|---|
| `/api/v1/me/**` — the whole subtree, including `/me/mcp-tokens` | Your account: email, password, devices, sessions — and the tokens themselves. A token that could mint tokens could mint a wider one and revoke the one you would use to cut it off. |
| `POST`/`PATCH`/`DELETE` on `/api/v1/workspaces/{id}/api-keys` (and `…/{id}/revoke`, `…/{id}/rotate`) | Minting an `atb_live_` key. The key's scopes are chosen by the caller and are **not** bounded by the MCP grant, so a token that could mint one could mint itself out of every limit above. `GET` (the list, prefixes only) is a read and works. |
| `PUT /api/v1/workspaces/{id}/mcp-settings` | `pii_mode` and `mcp_writeback_enabled` **are** the workspace's controls over what MCP tokens may do. An agent must not be able to widen the settings that govern agents. `GET` works — read them and tell your human what to change. |

<Callout type="info" title="Account administration stays a session act">
These are refusals by credential **class**, not by scope, so they are absent from each route's
`userToken` security scheme in `openapi.json` too — the published spec says exactly what the
runtime does. Sign in at [www.atribu.app](https://www.atribu.app) to manage your account, your
API keys and your workspace's MCP settings.
</Callout>

### Rate limits and revocation

A user token is metered against its own `rate_limit_units_per_minute`, in a bucket separate from
both API keys and browser sessions — so an agent's loop cannot rate-limit its owner's dashboard,
nor the reverse. A revoked or expired token answers `401` with the same envelope a revoked API key
gets. Revoke one from **Settings > Developer**, or disconnect the OAuth client that holds it.

## Rate limits

Default: **60 requests per minute** per key.

Heavy endpoints (customers, visitors, journey detail) cost 3x — meaning they consume 3 units of your rate limit per call.

### Endpoints with their own bucket

A few endpoints carry a sustained cap on top of the per-minute limit, because one call is far more work than a read. These buckets are separate from your per-minute allowance — they can neither consume it nor be consumed by it — and a 429 from one carries `Retry-After`.

| Endpoint | Own bucket | Why |
|----------|------------|-----|
| `POST /api/v1/quality/utm/refresh` | 6 scans per hour, per key | Each call runs a live 8–12 second UTM-health scan. Within 10 minutes of the last scan the endpoint returns the cached summary with `refreshed: false` instead of scanning again. |
| `POST /api/v1/profiles` | 200 calls per hour, per app credential | Each call can create a user, a workspace and a profile — all durable. |
| `POST /api/v1/ads/{id}/creative-analysis` | 30 calls per hour, per key — **and** a daily budget: 20 analyses per profile and 100 per API key, both per UTC day | Each accepted call queues a multimodal model call on the ad's video, so this is the one endpoint that spends the workspace's daily AI allowance. The hourly bucket is the burst guard; the daily budget is the real ceiling, and a `429` from it carries `Retry-After` set to the seconds until the next UTC midnight. A repeat request for the same ad and the same unchanged creative inside 24 hours returns the existing job and consumes nothing. See [Creative analysis](/docs/api/creative-analysis#queue-an-on-demand-analysis). |

| Header | Description |
|--------|-------------|
| `X-RateLimit-Limit` | Your total limit per minute |
| `X-RateLimit-Remaining` | Remaining requests in current window |
| `X-Request-Id` | Unique request ID for debugging |

When rate limited, you'll get a `429` response with a `Retry-After` header (seconds).

## Key rotation

Rotate keys with zero downtime:

<Steps>
<Step>
Call the **Rotate** endpoint — creates a new key
</Step>
<Step>
Both old and new keys work for **48 hours**
</Step>
<Step>
Update your integration with the new key
</Step>
<Step>
The old key automatically expires after the grace period
</Step>
</Steps>

## Security best practices

<Callout type="info" title="Server-to-server only">
This is a server-to-server API. Never expose keys in client-side JavaScript, mobile apps, or browser code.
</Callout>

- **Use the minimum scopes needed** — don't grant `customers:read` if you only need analytics
- **Rotate keys regularly** — the rotation endpoint makes this seamless
- **Revoke compromised keys immediately** — revocation is instant

## Error responses

```json title="Error response"
{
  "error": {
    "code": "unauthorized",
    "message": "Invalid or expired API key",
    "status": 401,
    "request_id": "req_a1b2c3d4"
  }
}
```

| Code | Status | Meaning |
|------|--------|---------|
| `unauthorized` | 401 | Missing or invalid API key |
| `insufficient_scope` | 403 | Key doesn't have the required scope |
| `rate_limit_exceeded` | 429 | Too many requests |
| `invalid_parameter` | 400 | Bad query parameter |
| `invalid_date_range` | 400 | Date range exceeds 366 days or dates are invalid |
| `internal_error` | 500 | Server error — include the `request_id` when reporting |

## Related

<Cards>
  <Card title="API Quickstart" href="/docs/api/quickstart">
    Make your first API call in 2 minutes
  </Card>
  <Card title="API Reference" href="/docs/api/overview">
    Full endpoint documentation
  </Card>
  <Card title="Team & Workspace" href="/docs/settings/team">
    Manage team access and roles
  </Card>
</Cards>

---

# Credentials

Three credential classes reach this API, and they are not interchangeable. The
distinction is not bureaucracy: it is what makes "a credential cannot promote
itself" true.

| credential | what it is | profile scope |
|---|---|---|
| `atb_live_…` **API key** | A machine credential minted **for exactly one profile**. No user identity, no consent screen. | one profile, implicitly |
| `atb_user_…` **MCP / user token** | A *person's* token, minted through the [MCP OAuth hand-off](/docs/api/quickstart) or in the console. Spans every workspace they belong to. | many — `?profile_id=` is **required** on profile-scoped routes |
| **Session** | The Supabase session the console itself holds. | whatever the person can open |

<Callout type="warn" title="A credential cannot administer its own class">
`/api/v1/me/**` and the workspace key routes are **session bearers only**. An
API key is minted for one profile — it is not a person, it has no MCP
credentials of its own, and it cannot mint, revoke or rotate a sibling key. All
of those answer `403 insufficient_scope` for a key, deliberately. Minting stays
a signed-in human's act.
</Callout>

## Minting an API key

<Tabs items={["In the app", "With the API / an AI agent"]}>
<Tab value="In the app">

**Settings → Developer → Create API Key**. Choose the scopes, copy the key. It
starts with `atb_live_` and is shown **once**.

</Tab>
<Tab value="With the API / an AI agent">

```http title="Endpoint"
POST /api/v1/workspaces/{workspaceId}/api-keys
```

Session bearer only, `profile_id` required, capped at 10 active keys per
profile. The plaintext secret comes back exactly once.

An agent that holds only an `atb_user_…` token does **not** need a key: since
#1084 that token is a first-class `/api/v1` principal on both REST and MCP.
Mint a key when a *machine* job should belong to a profile rather than to a
person — a nightly export, a CI check, a webhook consumer.

</Tab>
</Tabs>

`…/api-keys/{id}/rotate` issues a replacement and returns the new secret;
`…/revoke` kills one. Rotation is the safe move when a key may have leaked —
revoke alone leaves the job that used it broken until someone notices.

## Rotating a key

```http title="Endpoints"
POST /api/v1/workspaces/{workspaceId}/api-keys/{id}/rotate
POST /api/v1/workspaces/{workspaceId}/api-keys/{id}/revoke
```

**Rotate on a schedule you choose, and always after a suspected leak.** A key
has no expiry unless you gave it one, so "we rotate quarterly" is a decision
you make and we cannot make for you.

**Rotation is not revocation, and the difference is the whole point.** Rotating
mints a NEW secret, hands it back once, and leaves the old one working for **48
hours** — the window in which you deploy the new value. The rotated key's
status reads `rotated` until it expires on its own. Revoking kills a key
**immediately**, which is right for a leak you are certain of and wrong for a
routine change: the job that was using it breaks at that instant.

The sequence for a routine rotation:

1. `POST …/rotate` — store `data.key` somewhere your deployment reads.
2. Deploy. Watch
   [`GET /api/v1/workspaces/{workspaceId}/api-usage`](/docs/api/workspaces) for
   the old key: once its `requests` stops climbing, nothing is still using it.
3. Nothing else. The old secret expires by itself at the 48-hour mark; you do
   not have to remember to revoke it.

**For a leak, invert it.** Revoke first, then mint. A 48-hour grace window is
48 hours the leaked secret still works.

**A delegated key is different.** A key minted for a consumer app through the
OAuth flow is cut by revoking the *authorization*, not the key — see
[Partners](/docs/api/partners). Revoking a grant cascades to every key it
minted for that profile, which is what a tenant withdrawing an app expects and
what a per-key revoke cannot promise.

**If you are Atribu ops and a consumer's credentials are the incident**, the
lever is `suspend_oauth_app`, documented in the
[incident runbook](https://github.com/AtribuCore/atribu/blob/main/docs/runbooks/api-consumer-incident.md).

## MCP tokens

```http title="Endpoints"
GET  /api/v1/me/mcp-tokens
POST /api/v1/me/mcp-tokens
POST /api/v1/me/mcp-tokens/{tokenId}/rotate
POST /api/v1/me/mcp-tokens/{tokenId}/revoke
GET  /api/v1/me/mcp-oauth-grants
GET  /api/v1/me/mcp-usage
```

Capped at 10 active tokens per user. `mcp-oauth-grants` lists the MCP *apps* a
person has connected through OAuth — a different object from a static token,
revocable independently. `mcp-usage` reports units against the cap, summed
across every workspace the caller belongs to, at the highest-cap active
subscription's tier — the same resolution the MCP server itself uses.

For an agent, the OAuth hand-off is the intended path and needs no console
visit at all: see the [API quickstart](/docs/api/quickstart) and the
[MCP quickstart](/docs/mcp/quickstart).

## Scopes

The full scope catalogue, generated from the source of truth, is on
[Authentication](/docs/api/authentication). Two rules that catch people out:

- An MCP scope is an **intersection** of what the human granted and what their
  membership already allows. `mcp:read` maps to `analytics:read` +
  `campaigns:read`; no MCP scope reaches exports, commerce or messaging.
- A `403 insufficient_scope` names the scope it wanted whenever the scope is
  known by name — see [Errors](/docs/api/errors).

{/* BEGIN GENERATED ENDPOINT TABLE — generated by scripts/docs/generate-api-docs.ts from openapi.json. Do not hand-edit between these markers; run `npx tsx scripts/docs/generate-api-docs.ts`. */}

| Method | Path | What it does |
|---|---|---|
| `GET` | `/api/v1/me` | The signed-in user's own account |
| `PATCH` | `/api/v1/me` | Update the signed-in user's display name or locale |
| `DELETE` | `/api/v1/me` | Irreversibly delete the caller's account |
| `POST` | `/api/v1/me/email` | Start an email change |
| `DELETE` | `/api/v1/me/email` | Cancel a pending email change |
| `GET` | `/api/v1/me/mcp-oauth-grants` | The caller's connected MCP apps |
| `POST` | `/api/v1/me/mcp-oauth-grants/{id}/revoke` | Disconnect an MCP app |
| `GET` | `/api/v1/me/mcp-tokens` | The caller's MCP tokens |
| `POST` | `/api/v1/me/mcp-tokens` | Mint a new MCP token |
| `POST` | `/api/v1/me/mcp-tokens/{tokenId}/revoke` | Revoke one MCP token |
| `POST` | `/api/v1/me/mcp-tokens/{tokenId}/rotate` | Rotate one MCP token |
| `GET` | `/api/v1/me/mcp-usage` | The caller's current-period MCP usage |
| `POST` | `/api/v1/me/password` | Change the caller's password |
| `DELETE` | `/api/v1/me/sessions` | Sign out every other device |
| `DELETE` | `/api/v1/me/sessions/{sessionId}` | Revoke one session |

Generated from `openapi.json`. The full request and response schema for every operation is in the [OpenAPI document](https://www.atribu.app/api/v1/openapi.json).

{/* END GENERATED ENDPOINT TABLE */}

## Next steps

<Cards>
  <Card title="Step 4 — Credentials" href="/docs/getting-started/credentials">
    The golden path's own page for this step, both tracks
  </Card>
  <Card title="Authentication" href="/docs/api/authentication">
    The scope catalogue and rate limits
  </Card>
  <Card title="MCP authentication" href="/docs/mcp/authentication">
    OAuth 2.1, discovery, and token lifetimes
  </Card>
</Cards>

---

# Errors

{/* GENERATED by scripts/generate-error-docs.ts from API_ERROR_CATALOG (packages/atribu-api-contract/src/errors.ts). Do not hand-edit; run `npx tsx scripts/generate-error-docs.ts`. */}

Every error response shares one envelope, on both the Hono and Next-served `/api/v1/**` surfaces.

```json title="Error response"
{
  "error": {
    "code": "insufficient_scope",
    "message": "This API key does not have the 'campaigns:read' scope",
    "status": 403,
    "request_id": "req_a1b2c3d4",
    "docs_url": "https://www.atribu.app/docs/api/errors#insufficient_scope",
    "required_scopes": ["campaigns:read"]
  }
}
```

## Envelope fields

- `code` — the machine-readable code — see the table below for the full catalogue.
- `docs_url` — always present. Deep link to the section on this page for this exact `code`.
- `required_scopes` — present on `insufficient_scope` when the missing scope(s) are known by name. Mint a new key with one of these, or see [Authentication](/docs/api/authentication).
- `reason` — optional. A stable refinement of a SHARED class code that cannot be renamed (`forbidden`, `unauthorized`, `rate_limit_exceeded`, `provider_error`, `invalid_request`, `invalid_state`) — e.g. `forbidden` + `meta_permission_denied` (Meta refused) vs `forbidden` + `applied_by_not_allowed` (your `applied_by`). The rule: a refusal specific to one resource gets its own `code` (`campaign_not_found`, `campaign_not_pausable`), never a generic code plus a reason. Branch on `code`; use `reason` only to pick copy within a shared class.
- `reconnect_required / reconnect_url` — present when a connection's provider token was revoked and the caller must re-authorize.

---

## Error codes

### `unauthorized` — 401 [#unauthorized]

The request carries no valid credential — a missing, malformed, expired, or revoked API key or session. Include a valid Bearer token in the Authorization header.

---

### `forbidden` — 403 [#forbidden]

The credential is valid but not allowed to perform this action (for example, a workspace role that lacks the required permission).

---

### `insufficient_scope` — 403 [#insufficient_scope]

The API key or session does not carry a scope this route requires. When the missing scope is known, `required_scopes` names it — mint a new key with that scope, or have a workspace admin grant a role that includes it.

---

### `not_found` — 404 [#not_found]

The requested resource does not exist, or exists but is outside what this credential can read.

---

### `invalid_parameter` — 400 [#invalid_parameter]

A query parameter or path segment failed validation (wrong type, a malformed UUID, or a value out of range).

---

### `invalid_request` — 400 [#invalid_request]

The request itself is malformed — invalid JSON, the wrong content type, or a shape the endpoint cannot parse.

---

### `validation_error` — 422 [#validation_error]

The request body parsed but failed schema validation. `message` names the offending field.

---

### `invalid_content` — 400 [#invalid_content]

The request body's content — not just its shape — is invalid (for example, an empty attachment or the wrong multipart part).

---

### `invalid_date_range` — 400 [#invalid_date_range]

`date_from`/`date_to` failed the shared date-range contract: both required, `YYYY-MM-DD`, real calendar dates, `date_from` on or before `date_to`, and within the maximum span.

---

### `entitlement_required` — 409 [#entitlement_required]

The profile was provisioned by a consumer app that has not turned Atribu's attribution features on for it, so this write is not available. The app that provisioned the profile enables it with `PUT /api/v1/profiles/{profileId}/entitlements/atribu_attribution` (`{"enabled": true}`). Reads are unaffected, and a profile created directly in Atribu never sees this error.

---

### `healthcare_scope_forbidden` — 403 [#healthcare_scope_forbidden]

The profile belongs to a healthcare business (its workspace is marked as a healthcare agency, or the profile runs in `platform_safe` or `hipaa` privacy mode), and a partner app's grant for such a profile never carries `customers:read` or `visitors:read`, the person-level and per-visitor reads. Request `analytics` instead of `analytics_pii`. Keys minted for these profiles leave both out; read the projected journeys (`GET /api/v1/conversions/{id}/journey`, `GET /api/v1/customers/journey?customer_key=`) under `conversions:read` instead. Everything else in the `attribution` bundle, such as `campaigns:read`, `exports:read` and `campaigns:apply`, stays.

---

### `invalid_state` — 409 [#invalid_state]

The resource is not in a state that allows this operation (for example, a duplicate name, or an invitation already accepted or revoked).

---

### `rate_limit_exceeded` — 429 [#rate_limit_exceeded]

The caller exceeded its rate limit. Respect the `Retry-After` header before retrying.

---

### `connection_not_ready` — 409 [#connection_not_ready]

The named data connection exists but is not yet usable (for example, it is missing an access token).

---

### `connection_revoked` — 409 [#connection_revoked]

The connection's access was revoked (an app's authorization for this Messenger, Instagram or WhatsApp channel ended), so its token was deleted and nothing can be sent through it. Carries `reconnect_required: true`. Do not retry: reconnect the channel through the connect hand-off, which restores it. Its conversations are kept.

---

### `provider_error` — 502 [#provider_error]

An upstream provider (Meta, GoHighLevel, Gmail, Microsoft Graph, …) returned an error while fulfilling this request. 502 (or 503) means the failure is transient — the provider was unreachable, timed out or answered 5xx — and a retry may succeed. On an email send, a provider refusal Atribu has no more specific code for answers 422 with this code instead, carrying the provider's own words in `provider_code` / `provider_message`: the provider read the request and said no, so the identical retry will fail the same way.

---

### `account_suspended` — 403 [#account_suspended]

Email send: the mailbox's provider has suspended the account (for example Outlook.com `ErrorAccountSuspend`), so it cannot send anything until its owner acts at the provider — `provider_message` carries the provider's instructions. Do not retry. The connection now reports `status: "needs_action"` and a `channel.health.updated` event was pushed; the first send the provider accepts again clears it.

---

### `auth_revoked` — 403 [#auth_revoked]

Email send: the mailbox's OAuth grant is revoked, expired or missing the send permission (Microsoft Graph 401, Gmail 401, or `invalid_grant` when refreshing). Carries `reconnect_required: true`. The connection is marked `reconnect_required` and a `connection.reconnect_required` / `channel.health.updated` event was pushed; reconnect the mailbox through the connect hand-off.

---

### `mailbox_disabled` — 403 [#mailbox_disabled]

Email send: the account has no usable mailbox, or mail is turned off for it (for example Gmail `Mail service not enabled`, or a disabled Microsoft 365 mailbox). The owner or their tenant administrator must enable it. Do not retry; the connection reports `status: "needs_action"` until a send succeeds.

---

### `quota_exceeded` — 429 [#quota_exceeded]

Email send: the mailbox hit its own quota at the provider — its storage is full, or it reached the provider's daily sending limit. Respect `Retry-After`; if storage is full the owner has to free space. The connection reports `status: "needs_action"` until a send succeeds. Distinct from `rate_limit_exceeded`, which is Atribu's own per-mailbox cap or provider throttling.

---

### `recipient_rejected` — 422 [#recipient_rejected]

Email send: the provider refused this recipient (an invalid or unknown address). About this message only — other sends through the mailbox are unaffected and its health is not changed. Fix the address before retrying.

---

### `service_unavailable` — 503 [#service_unavailable]

A dependency this endpoint needs is not configured, or is temporarily down.

---

### `api_key_required` — 403 [#api_key_required]

This route is for a partner app's API key only (`POST /api/v1/customers/erasures`). A person erases a customer with `POST /api/v1/workspaces/{workspaceId}/customer-erasures`, as a workspace owner or admin, so the audit record names them.

---

### `customer_not_found` — 404 [#customer_not_found]

No customer of this profile matches the selector you sent (your `customer_key`, an e-mail, a phone or another identifier). Nothing was changed. A customer of another profile answers the same. Distinct from `not_found`, which also covers a route this deployment does not serve.

---

### `account_connected_elsewhere` — 409 [#account_connected_elsewhere]

The account you chose is already connected to another profile of this workspace. One provider account is connected to one profile of a workspace at a time: disconnect it from the other profile first, or choose a different account. Nothing was written. The same code is a hand-off's `reason` when a consent resolves to such an account.

---

### `conversion_not_found` — 404 [#conversion_not_found]

No conversion with that handle (`outcome_event_id`, conversion id, or your own event key) in this profile. Another profile's conversion answers the same. Distinct from `not_found`, which also covers a route this deployment does not serve.

---

### `event_not_found` — 404 [#event_not_found]

No event ingested through `POST /api/v1/events` in this profile has that `idempotency_key`. Nothing was retracted. Another profile's key answers the same. A key you already retracted is not this error: it answers `200` with `status: "already_retracted"`.

---

### `event_already_exported` — 409 [#event_already_exported]

The event has a conversion export (queued, sent, failed or skipped), so an ad platform may have seen it, and it is not retracted. Nothing was deleted. Do not retry; contact support to correct it at the destination.

---

### `event_linked_to_conversation` — 409 [#event_linked_to_conversation]

A WhatsApp or Instagram conversation recorded this event as its lead (a lead Atribu captured natively was merged onto your event), so retracting it would delete that lead too. Nothing was deleted. Do not retry; contact support.

---

### `erasure_not_found` — 404 [#erasure_not_found]

No erasure receipt with that `erasure_id` in this profile. Receipts are per profile; another profile's id answers the same.

---

### `link_tokens_disabled` — 503 [#link_tokens_disabled]

Link-identity tokens (`atb_t`) are turned off on this deployment: no signing secret is configured, so `POST /api/v1/customers/link-tokens` mints nothing and the tracker ignores tokens. The feature is off, not down, so do not retry. Send your links without `atb_t` until it is turned on.

---

### `feature_retired` — 410 [#feature_retired]

This endpoint was retired on purpose and answers this until its `Sunset` date; the message names what replaced it. The in-app chat routes (`POST /api/v1/dashboard/chat`, `POST /api/v1/workspaces/{workspaceId}/top-performers/chat`) answer it since 2026-10-10: ask the same question from your own MCP client connected to `https://mcp.atribu.app`. Do not retry.

---

### `not_implemented` — 501 [#not_implemented]

This capability is not available on this deployment, or not yet for this provider.

---

### `internal_error` — 500 [#internal_error]

An unexpected server error. `request_id` identifies it for support.

---

### `calendar_scope_required` — 403 [#calendar_scope_required]

The connection's Google grant is missing the calendar scope this operation needs (`calendar.app.created` to manage calendars, `calendar.acls` to share).

---

### `calendar_unsupported` — 422 [#calendar_unsupported]

The request targeted a calendar Atribu doesn't manage (for example `primary`, or an unknown id). Booking operations only run on Atribu-created calendars.

---

### `provider_not_configured` — 501 [#provider_not_configured]

A read-only external-calendar provider (Microsoft) has no app registration on this deployment, so the endpoint cannot serve it yet.

---

### `invalid_window` — 400 [#invalid_window]

The requested read-events time window exceeds the endpoint's 90-day cap.

---

### `media_expired` — 410 [#media_expired]

The referenced media id has expired or the provider no longer serves it. WhatsApp webhook media ids expire after 7 days.

---

### `whatsapp_register_limit` — 429 [#whatsapp_register_limit]

WhatsApp phone-number registration is capped at 10 attempts per number per 72 hours. This is propagated verbatim from Meta and is never retried server-side — wait out the window.

---

### `whatsapp_payment_required` — 402 [#whatsapp_payment_required]

The WhatsApp Business Account has no attached payment method, or its funding source was declined.

---

### `send_outcome_unknown` — 422 [#send_outcome_unknown]

WhatsApp, Instagram or Messenger send: Meta answered with a server error (5xx) or the connection broke after the request reached it, so Atribu cannot tell whether the message was delivered — and in practice Meta usually has delivered it. Do not retry automatically: a retry sends the customer a second copy. The same `Idempotency-Key` replays this answer for 24 hours. If the message did go out, its status webhook (`sent` / `delivered`) still arrives.

---

### `messaging_window_closed` — 422 [#messaging_window_closed]

The conversation's messaging window is closed, so nothing was sent. Messenger: 24 hours after the person's last message, or 7 days for a human agent's reply sent with `human_agent: true`. TikTok: 48 hours after the person's last message, at most 10 messages in that time, and never a conversation the person did not start. Wait for the person to write again, or continue on another channel.

---

### `messenger_feature_not_approved` — 403 [#messenger_feature_not_approved]

Meta refused the Messenger send because a feature or permission it needs (for example the Human Agent tag, or page_messaging_subscriptions) is not approved for the Atribu app yet. This is not a closed messaging window and retrying will not help until Meta approves it. Nothing was sent.

---

### `whatsapp_otp_relay_unavailable` — 503 [#whatsapp_otp_relay_unavailable]

The OTP-capture relay's store is unreachable, so a captured code cannot be held for the connect page.

---

### `audit_timeout` — 504 [#audit_timeout]

The on-demand UTM-health scan did not finish inside the request budget (Postgres cancelled the statement, or the gateway gave up first). The previously cached summary is still readable; retry the scan later.

---

### `database_unavailable` — 503 [#database_unavailable]

Atribu's database could not be reached while serving this request (the connection dropped or was refused). Nothing about your request was wrong. Retry the same request after `Retry-After` seconds: the routes that answer this code are idempotent, so a request that was partly applied converges on retry.

---

### `weak_password` — 422 [#weak_password]

The new password is shorter than the minimum required length.

---

### `compromised_password` — 422 [#compromised_password]

The new password appears in a public breach corpus (HaveIBeenPwned k-anonymity check).

---

### `current_password_required` — 400 [#current_password_required]

The caller has an email/password identity, so proving the current password is required, and the request omitted it.

---

### `current_password_invalid` — 400 [#current_password_invalid]

The supplied current password did not verify against the caller's identity.

---

### `plan_upgrade_required` — 402 [#plan_upgrade_required]

This workspace-admin write needs a plan this workspace is not on. The remedy is upgrading the plan, not a different role.

---

### `whatsapp_service_window_expired` — 422 [#whatsapp_service_window_expired]

The WhatsApp customer-service window (24 hours since the customer's last message) has closed. Use a template or interactive message instead.

---

### `payload_too_large` — 413 [#payload_too_large]

The uploaded media exceeded the provider's per-type size cap (5MB image / 16MB video or audio / 100MB document), a WhatsApp template's `header_media` sample (`POST /api/v1/whatsapp/templates`) is over its format's cap (5MB image / 16MB video / 100MB PDF), or a WhatsApp media resolve (`GET /api/v1/whatsapp/media/{mediaId}`) named media over the 100MB download cap.

---

### `profile_limit_reached` — 403 [#profile_limit_reached]

The workspace's active_profiles plan limit is already reached. Carries `upgrade_required: true` and `upgrade: { plan }` — the fix is adding capacity (a plan upgrade, or on Agency/Enterprise a support-granted override), not a different credential.

---

### `idempotency_key_conflict` — 409 [#idempotency_key_conflict]

The Idempotency-Key header was already used for a different operation, or for the same operation with a different request body, than the one it first claimed. Not retryable as sent: generate a fresh key per logical write attempt, or resend the original body under the original key.

---

### `idempotency_key_in_flight` — 409 [#idempotency_key_in_flight]

The first request carrying this Idempotency-Key has not finished yet, so the retry was refused instead of risking a second delivery. Retry the same key with backoff: once the original finishes, the same key replays its response for 24 hours.

---

### `demo_purge_blocked` — 409 [#demo_purge_blocked]

A database trigger refused part of the delete because the rows are audit evidence. The demo profile is archived and its seeded data removed; the conversion-export and signal-audit rows stay attached to it. Retrying will not change this — nothing further is needed.

---

### `workspace_archived` — 410 [#workspace_archived]

The invitation's workspace has been archived. Archiving is terminal — the workspace will never come back, so a new invitation to it would fail the same way. Not retryable; ask to be invited to a different, active workspace.

---

### `app_provisioned_workspace` — 403 [#app_provisioned_workspace]

The workspace was provisioned by a consumer app (`acquisition_source = 'oauth'`), and Atribu sends its people no email at all — the app that owns the relationship delivers its own invitations and reports. No credential changes this.

---

### `audience_tos_required` — 428 [#audience_tos_required]

Meta has no record that this ad account accepted its Custom Audience terms, so it refuses to create the audience. Nothing about the request or the credential is wrong and no retry will help: the advertiser must accept the terms for that ad account in Meta Ads Manager, then confirm the proposal again.

---

### `platform_safe_audiences_disabled` — 409 [#platform_safe_audiences_disabled]

The profile runs in Platform-Safe mode, where Conversion Sync audiences (value-based seeds, their lookalikes, exclusion and retargeting lists) are never sent to Meta. The proposal or confirmation was refused before any Meta call, so no audience was created. The only audience a Platform-Safe profile can have is the partner customer list (`/api/v1/partner/audiences/customer-list`), which is enabled per profile.

---

### `customer_not_in_profile` — 422 [#customer_not_in_profile]

The named customer is not an active customer of this profile — either it belongs to a different profile, or it has been merged into another customer.

---

### `platform_safe_required` — 409 [#platform_safe_required]

The profile is not in Platform-Safe mode, so it has no use for a Clean Dataset — and creating one spends one of the ad account's self-created-pixel slots. Switch the profile first with `PUT /api/v1/conversion-sync/catalog` (`privacy_mode: "platform_safe"`), then provision.

---

### `meta_write_permission_missing` — 403 [#meta_write_permission_missing]

The profile's Meta Ads connection was granted read-only access — its recorded grant does not include `ads_management` — so Atribu cannot pause, resume or re-budget ads through it. Nothing is wrong with your key and no retry helps: the advertiser must reconnect Meta Ads and accept the "manage advertisements" permission, then retry.

---

### `campaign_not_found` — 404 [#campaign_not_found]

No campaign with this Meta id belongs to the profile your credential is scoped to. A campaign in another profile answers exactly the same, and so does a malformed id. Take the id from the `platform_id` of a `GET /api/v1/campaigns` row. A path that matches no route at all is still `not_found`.

---

### `template_not_found` — 404 [#template_not_found]

No WhatsApp template with this name AND language exists in the connection's template cache (Meta ids are per name + language). A template created minutes ago appears after `POST /api/v1/whatsapp/templates/sync`. A path that matches no route at all is still `not_found`.

---

### `insights_enabling` — 409 [#insights_enabling]

Template insights were off on this WhatsApp Business Account, so Atribu just switched them on (`is_enabled_for_insights=true`, once). Meta needs a few minutes before template analytics are readable. Retry after `retry_after_seconds` (also sent as `Retry-After`); nothing else is required.

---

### `insights_unsupported` — 422 [#insights_unsupported]

Meta refuses to switch template insights on for this WhatsApp Business Account — it is a WhatsApp Business app (coexistence / SMB) account, and Meta answers `(#10) This operation can not be performed on SMB business type`. Meta does not serve template analytics for it; retrying does not help.

---

### `meta_unavailable` — 502 [#meta_unavailable]

Meta answered with a server error or could not be reached while Atribu read WhatsApp template analytics. Transient — retry later.

---

### `campaign_not_pausable` — 409 [#campaign_not_pausable]

Only an ACTIVE campaign can be paused, and this one is ARCHIVED or DELETED on Meta. Nothing was written — Meta would un-archive it on a status write — and the refused attempt is audited. `GET /api/v1/campaigns/{id}/status` shows the live status.

---

### `campaign_not_resumable` — 409 [#campaign_not_resumable]

Only a PAUSED campaign can be resumed, and this one is ARCHIVED or DELETED on Meta: a resume never resurrects it. Nothing was written; the refused attempt is audited. (Resuming a campaign that is already ACTIVE is not an error: it answers `200` with `outcome: "no_change"`.)

---

### `campaign_not_editable` — 409 [#campaign_not_editable]

The campaign is ARCHIVED or DELETED on Meta, so its budget cannot be changed. Atribu reads the status live from Meta. Nothing was written — a retired object is not edited — and the refused attempt is audited.

---

### `adset_not_editable` — 409 [#adset_not_editable]

The ad set, or the campaign it belongs to, is ARCHIVED or DELETED on Meta, so its budget cannot be changed. Atribu reads the status live from Meta (falling back to its last synced status only when Meta returns none). Nothing was written to Meta — a retired object is not edited, and a status write would un-archive it — and the refused attempt is audited.

---

### `budget_change_out_of_bounds` — 422 [#budget_change_out_of_bounds]

The requested budget (daily or lifetime, ad set or campaign) moves more than ±50% of the live budget of that type in one step, or falls below Atribu's floor of 100 minor units. Nothing was written to Meta (the refused attempt is still audited). Apply a smaller step now and another one later. Atribu does not read Meta's own per-account minimum, which is higher in most currencies (in CLP, about 1,000 pesos): a budget between the two is attempted and Meta's refusal arrives as `validation_error` with Meta's message.

---

### `clean_dataset_has_live_ad_sets` — 409 [#clean_dataset_has_live_ad_sets]

Switching this Platform-Safe profile back to its existing Meta dataset would stop the ad sets that optimize on its Clean Dataset from receiving conversions, and some are still delivering (or Meta could not be read to check). Move or pause those ad sets first, or resend `POST /api/v1/conversion-sync/platform-safe/target` with `confirm_live_ad_sets: true`.

---

### `meta_connection_required` — 409 [#meta_connection_required]

This operation needs the profile's Meta Ads connection (its token and ad account), and none is usable: no enabled Meta CAPI destination names one, and the profile does not have exactly one connected Meta Ads account to fall back to. Connect Meta Ads through the connect rail (or create the Meta CAPI destination on the connection to use), then retry.

---

### `consent_required` — 409 [#consent_required]

The automated duplicate-and-swap creates new ad sets (new ids, a Meta learning-phase reset, a possible window of double spend), so an API key or MCP user token must prove a person accepted the current consent text. The `consent` object was missing, named another version, hashed another text, or was accepted more than 24 hours ago. `error.consent` names the current `version` and `text_hashes`: render that text verbatim (see Partners → Automated duplicate-and-swap), have the person accept it, and re-send with `consent: { version, text_hash, accepted_by, accepted_at }`. Nothing was sent to Meta.

---

### `amount_invalid` — 422 [#amount_invalid]

The refund amount is zero or negative, or exceeds the payment's value minus what the provider has already reversed for it.

---

### `manual_refund_exists` — 422 [#manual_refund_exists]

This payment already has a manually recorded refund. Undo it (DELETE the refund) before recording a new amount — there is at most one manual refund per payment.

---

### `payment_assignment_incomplete` — 502 [#payment_assignment_incomplete]

The payer assignment was recorded but has not yet propagated to where attribution reads it. Retry the request.

---

### `payment_ref_invalid` — 422 [#payment_ref_invalid]

The payment reference built for this payment was malformed.

---

### `status_invalid` — 422 [#status_invalid]

The `status` query parameter must be `all` or `unidentified`.

---

### `range_invalid` — 422 [#range_invalid]

`from` and `to` must both be present, parseable timestamps, with `from` on or before `to`.

---

### `unknown_answer_key` — 422 [#unknown_answer_key]

The `answer_key` is not in the declared-source vocabulary. Map the answer onto one of the listed keys, or send `other` with the customer's own words in `answer_text`. Nothing was written.

---

### `unknown_customer_ref` — 422 [#unknown_customer_ref]

`customer.external_ref` names no live customer on this profile — it was never sent as `user_traits.external_id`, the customer was erased, or it belongs to another profile — and no `customer.email` / `customer.phone` came with it. Atribu never creates a customer from a reference alone. Send the email and/or phone with the ref (Atribu then creates the customer and binds the ref), send the person first (`POST /api/v1/events` with `user_traits.external_id`), or name them by `wa_id`. Nothing was written.

---

### `whatsapp_connection_required` — 409 [#whatsapp_connection_required]

The WhatsApp dataset is created on the profile's WhatsApp Business Account, and the profile has no live one: none is connected, or its connection is disconnected. Nothing was written and nothing was sent to Meta. Connect WhatsApp (embedded signup, or the registration routes for a shared WABA), then retry. `GET /api/v1/conversion-sync/data-sources` reports this as `reason_code: "whatsapp_not_connected"`.

---

### `instagram_connection_required` — 409 [#instagram_connection_required]

The Instagram dataset is created on the profile's Instagram professional account, and the profile has none connected. Nothing was written and nothing was sent to Meta. Connect Instagram, then retry. `GET /api/v1/conversion-sync/data-sources` reports this as `reason_code: "instagram_not_connected"`.

---

### `instagram_facebook_login_required` — 409 [#instagram_facebook_login_required]

The Instagram dataset can only be created for an Instagram account connected through Facebook Login for Business (linked to a Facebook Page). The profile's Instagram account is connected through Instagram Login, which Meta does not allow to create datasets. Nothing was created and nothing was sent to Meta. Reconnect Instagram through Facebook Login for Business, then retry. `GET /api/v1/conversion-sync/data-sources` reports this before the call as `reason_code: "instagram_facebook_login_required"`.

---

### `destination_conflict` — 409 [#destination_conflict]

Another export destination of this profile already exports to the same place — same kind, same connection, and the same `meta_pixel_id`, `google_conversion_action` or `tiktok_pixel_code`. A disabled destination still holds its place. `conflicting_destination_id` names that destination: update or re-enable it (`PATCH /api/v1/exports/destinations/{id}`) instead of creating a duplicate, or choose a different pixel. Nothing was written.

---

### `unknown_ad` — 422 [#unknown_ad]

`ad_id` is not one of this profile's Meta ads — Atribu's Meta sync has neither the ad nor any delivery (spend) for it on this profile. Atribu records a referral only for the profile's own ads, so an unknown or foreign id never reaches ad-level reports. Check the id belongs to this business's ad account. An ad archived or deleted before the account was connected is known only through its spend, so one with no spend in the synced history cannot be recorded. Nothing was written.

---

# Deprecation policy

Two companies build products on this API. This page is the commitment we make to them
about what a removal looks like — stated so that a change you did not plan for cannot
reach you as an outage.

## The commitment

**An operation we intend to remove is announced at least 90 days before it stops
answering.** Not 90 days before we decide; 90 days before the last request succeeds.

That window is enforced, not promised. Setting a removal date closer than 90 days after
the deprecation notice fails the build — `validateRouteSunset` in
`services/api/src/routes/manifest.ts` refuses the pair, and a deprecation with no date
yet is the supported way to say *going away, date to be decided*.

**Nothing is removed silently.** Every deprecation appears in the
[changelog](/docs/api/changelog), in the response headers below, and in the OpenAPI
document, all derived from one field on the route. There is no separate list anyone has
to remember to update.

## How you find out, without asking

A deprecated operation answers exactly as it did before — same status, same body — and
adds three standard headers:

```http
Deprecation: @1789689600
Sunset: Wed, 02 Dec 2026 00:00:00 GMT
Link: </api/v1/goals/definitions>; rel="successor-version"
```

- **`Deprecation`** ([draft-ietf-httpapi-deprecation-header](https://datatracker.ietf.org/doc/draft-ietf-httpapi-deprecation-header/))
  — a unix timestamp, prefixed `@`, of the moment the notice took effect. Present on
  every deprecated operation.
- **`Sunset`** ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)) — an HTTP-date. The
  operation is expected to stop answering at or after this instant. **Absent when no
  removal date has been decided**, which is a real and common state: the warning is
  live, the clock is not running.
- **`Link` with `rel="successor-version"`** — where to go instead. Present whenever a
  replacement exists. Absent when the capability is genuinely going away rather than
  moving.

**Log these three, do not parse the body for them.** They arrive on every response from
the affected operation, including error responses, so a single client-side check on any
call catches the notice — you do not have to be watching this page.

The same three facts are in the published spec: a deprecated operation carries
`deprecated: true` and an `x-atribu-sunset` extension in
[`openapi.json`](https://api.atribu.app/api/v1/openapi.json), so a codegen tool sees the
notice without a live request.

## What counts as a breaking change

These get the 90-day notice and a **Breaking** entry in the changelog:

- removing an operation, or a field from a response;
- narrowing what an existing parameter accepts;
- changing the TYPE of a response field, including a field that gains `null` — a value
  that was always a number and is now sometimes `null` breaks arithmetic that never had
  a guard;
- changing the meaning of a value without changing its name;
- adding a required parameter to an existing operation;
- requiring a scope an existing credential does not hold.

These do **not**, and can ship any day:

- adding an operation;
- adding an optional parameter;
- adding a field to a response — **write your client to ignore fields it does not
  know**; we add them regularly;
- adding a value to an enumerated field whose documented meaning is "one of at least
  these" — check your parser's default branch;
- performance, wording of a `message`, or anything behind a flag that is off for you.

**A security fix is the one exception, and it is a real one.** Closing a hole that lets
a credential reach data it was never granted takes effect when the fix ships. #1152 and
#1154 are both worked examples: an `mcp:read` token that could create workspaces stopped
being able to, and a webhook target pointing at a private address stopped being
delivered to, on the day each fix landed. Both are in the changelog marked **Breaking**,
with what to change.

## What we ask of you

- **Ignore unknown response fields.** A strict parser turns every additive change into
  an outage on your side.
- **Do not depend on key order, on the exact wording of `message`, or on a `request_id`
  format.** Branch on `error.code`; it is the stable half of the envelope.
- **Read `X-RateLimit-Remaining` and `X-RateLimit-Reset`** rather than discovering your
  ceiling as a `429`.
- **Keep one contact address current with us.** A deprecation that only exists in a
  header nobody logs is a notice in name only, and we would rather email you.

## When something is already broken

If an endpoint you rely on is failing right now, that is an incident, not a deprecation:
see the [incident runbook](https://github.com/AtribuCore/atribu/blob/main/docs/runbooks/api-consumer-incident.md)
for what we do, including the kill-switch we hold and when we use it. The
[SLO](https://github.com/AtribuCore/atribu/blob/main/docs/api-slo.md) states what
"working" means in numbers.

---

# Changelog

{/* GENERATED by scripts/generate-changelog-docs.ts from API_CHANGELOG (packages/atribu-api-contract/src/changelog.ts) plus every route carrying deprecatedAt. Do not hand-edit; run `npx tsx scripts/generate-changelog-docs.ts`. */}

Developer-facing changes to `https://api.atribu.app/api/v1/**`. See the [deprecation policy](/docs/api/deprecation-policy) for what a removal looks like before it happens.

## Active deprecations

| Operation | Deprecated | Sunset | Successor |
|---|---|---|---|
| `POST /api/v1/goals` | 2026-09-05 | 2026-12-04 | `/api/v1/goals/definitions` |
| `POST /api/v1/dashboard/chat` | 2026-10-10 | 2027-01-08 | — |
| `POST /api/v1/workspaces/{workspaceId}/top-performers/chat` | 2026-10-10 | 2027-01-08 | — |
| `GET /api/v1/conversion-sync/wiring/candidate-ad-sets` | 2026-10-21 | not scheduled | `/api/v1/conversion-sync/wiring/campaigns` |

## Changes

Each line links the issue that carries the full reasoning — what broke, what to change, and why the decision went the way it did.

### 2026-10-23

- **Changed** · [#2223](https://github.com/AtribuCore/atribu/issues/2223) — A `platform_safe` or `hipaa` profile sends NO URL to Meta or TikTok: no `event_source_url`, not even the origin, on every event, `website` events included. `privacy_config.url_sanitization` accepts `omit`; in those two modes `PUT /api/v1/conversion-sync/catalog` floors any other value to `omit` and the catalog returns `omit`. Meta accepts website events without the URL; matching relies on `user_data` and fbc/fbp. Standard and custom profiles are unchanged.
- **Breaking** · [#2208](https://github.com/AtribuCore/atribu/issues/2208) — `GET` / `POST /api/v1/conversion-sync/platform-safe/repoint` and `POST /api/v1/conversion-sync/platform-safe/go-live` are DELETED (not deprecated) and answer `404`. Ad sets move onto a Clean Dataset — from any Source dataset — through Conectar campañas: `GET /api/v1/conversion-sync/wiring/campaigns` and `POST /api/v1/conversion-sync/wiring/ad-sets`. `POST …/platform-safe/target` still lists `ad_sets_to_recreate`. SDK `@atribu/node` 4.0.0 drops `platformSafeRepointPreview`, `applyPlatformSafeRepoint` and `startPlatformSafeGoLive`.
- **Breaking** · [#2207](https://github.com/AtribuCore/atribu/issues/2207) — Every Recreate asks what happens to the ORIGINAL ad set. `pause_original` (`true` pauses it once the copy is live, `false` leaves it running) is REQUIRED, with no default, for every ad set a request recreates — a missing answer is `400 validation_error` naming the ad sets, nothing queued. Keyed by ad set id on `wiring/ad-sets`, a field on each `ad_sets[]` row on `rewire`. `duplicate_swap_mode` is retired (400). In-place edits need no answer.

### 2026-10-22

- **Changed** · [#2201](https://github.com/AtribuCore/atribu/issues/2201) — Meta Conversion Sync ships ONE event per sale: when outcomes carry the same `properties.order_id`, only the first event of a given name reaches each destination (e.g. `closed_won` and `payment_received` both mapped to `Purchase` send one Purchase). The others are held as `order_already_sent` / `order_duplicate_in_batch`; an outcome without `order_id` ships as before. New rule setting `source_config.closed_won_requires_payment` (default `true`): `false` lets that rule's `closed_won` ship to Meta and Google without a confirmed payment.
- **Changed** · [#2197](https://github.com/AtribuCore/atribu/issues/2197) — A declared `action_source: system_generated` is refined by Atribu's own evidence on Meta export: when Atribu's tag captured the same event on the site (the outcome's event id is the browser event's id) it ships as `website` with the page URL; when the buyer came from a click-to-WhatsApp ad and the event has no web footprint it goes ONLY to the WhatsApp dataset as `business_messaging`. Ledger filters `action_source_refined:*`. Every other declared value is unchanged.
- **Added** · [#2173](https://github.com/AtribuCore/atribu/issues/2173) — `GET /api/v1/conversion-sync/wiring/campaigns` takes `edit_mode=skip` to answer without the validate_only probes (every offerable ad set reads `edit_mode: unknown`, `probe_limit: 0`) in a fraction of the time. New `GET /api/v1/conversion-sync/wiring/campaigns/edit-modes?destination_id=&rule_id=` (`exports:read`) runs those probes for one rule. Without `edit_mode` the read is unchanged.

### 2026-10-21

- **Breaking** · [#2154](https://github.com/AtribuCore/atribu/issues/2154) — `POST /api/v1/exports/test` now REQUIRES `test_event_code` and answers `422 validation_error` without one, before Meta is called. Without the code Meta filed the event on the live dataset as a real conversion. Copy the code from Events Manager → Test Events.

### 2026-10-15

- **Added** · [#2117](https://github.com/AtribuCore/atribu/issues/2117) — New `GET /api/v1/conversions/attribution` (`analytics:read` or `campaigns:read`): every conversion of a window with the campaign that wins its credit — highest `credited_weight`, a direct credit beating an inherited one on a tie — in one keyset-paginated walk (`limit` up to 1000, opaque `cursor`). Replaces reading `GET /api/v1/campaigns/{id}/conversions` once per campaign and folding the rows; that route is unchanged.
- **Added** · [#2116](https://github.com/AtribuCore/atribu/issues/2116) — Two webhook events, provider `shopify`: `customer.redacted` (Shopify's `customers/redact`; `data` = `shop`, `customer_id`, `email`, `phone`, `orders_to_redact`) and `shop.redacted` (Shopify's `shop/redact`; `data` = `shop`). Atribu erases its own copy, then forwards the request so an app that stored buyer data from the store's order events erases it too. Opt-in: add them to a subscription's `events` with provider `shopify`.
- **Changed** · [#2106](https://github.com/AtribuCore/atribu/issues/2106) — The `order.status.changed` webhook (provider `shopify`) fires for every order, not only shipped ones: `status` is the order's current milestone (`created`, `paid`, `fulfilled`, `delivered`, `refunded`, `cancelled`), one event per (order, status). `data` adds what a subscriber needs to match the order: `attributes` (cart / note attributes, so a cart permalink's `attributes[ref]` comes back), `lines[]` (`variant_id`, `quantity`, `sku`, `price`, …), `financial_status`, `fulfillment_status`, `name`, `total_price`, `currency`, `customer_id`, `cart_token`, `checkout_token`, timestamps, and the buyer's `email` / `phone` with fallbacks (null when Shopify withholds them). A consumer that only handles `fulfilled` / `delivered` should ignore the other statuses.
- **Added** · [#2105](https://github.com/AtribuCore/atribu/issues/2105) — A live Shopify read passthrough at the new `commerce:live` scope: a connected store's catalogue (every variant, current stock) and its orders updated since a time (buyer email and phone, cart attributes, every line), read from Shopify in the request and never from Atribu's copy. Keyset-paginated, throttle-tolerant. The new `commerce` connect scope mints `commerce:read` + `commerce:live`.
- **Added** · [#2104](https://github.com/AtribuCore/atribu/issues/2104) — The connect hand-off accepts `shopify`, with the store in `shop` (`<handle>.myshopify.com`, the bare handle or an admin URL). The merchant installs Atribu's Shopify app — or the custom-distribution app registered for that store — and returns to your `return_url` with no Atribu login and no plan page. The connection's lifecycle reaches your `atribu` subscription as `connection.connected`, `connection.revoked` (on uninstall) and `connection.reconnect_required`, with `data.provider: "shopify"`.
- **Changed** · [#2101](https://github.com/AtribuCore/atribu/issues/2101) — `kpis.repeat_rate_pct`, `kpis.median_days_to_second`, `repeat_purchase_histogram` and the months after the first in `cohort_retention` now count a REPEAT payment the way `GET /api/v1/overview` does: a payment that is not part of the customer's first purchase (`is_first_payment = false` — a balance charged within 7 days of the deposit, or another order of the same first purchase, is not a repeat). On one profile the repeat rate read 67 % where 1 of 6 new customers had actually paid again.
- **Fixed** · [#2101](https://github.com/AtribuCore/atribu/issues/2101) — `credited_revenue` on a touch is cash only: it no longer adds the estimated value of a credited appointment or lead (one customer showed USD 4,300 credited to a touch after paying USD 800).
- **Added** · [#2101](https://github.com/AtribuCore/atribu/issues/2101) — Each row also returns `meta_reported_conversions` (Meta's whole conversion figure for the entity) and `meta_reported_chats` (its chats started), so a client can account for every part of Meta's number.
- **Changed** · [#2098](https://github.com/AtribuCore/atribu/issues/2098) — `business_conversions` is now the canonical conversion count — the one `GET /api/v1/overview` returns as `current.outcomes` and Atribu's own screens print — over the last `days` calendar days ending today in the profile's time zone. It used to count every `conversions` row in the tracker's rolling window, including suppressed conversions (e.g. GoHighLevel SaaS recharges classified as not sold by ads), installments and refunds/disputes; on one profile it read 111 where the app read 86.
- **Fixed** · [#2098](https://github.com/AtribuCore/atribu/issues/2098) — `signals.checkout_metadata.cash_events` (and the split under it) counts the payments `GET /api/v1/overview` counts as `cash_payments`: a payment whose conversion is suppressed (e.g. a recharge not sold by ads) or an installment is no longer in it.
- **Fixed** · [#2098](https://github.com/AtribuCore/atribu/issues/2098) — `/attribution/models`: a campaign row's `conversion_count` (and its lead / appointment / payment counts) counts a conversion only where the model gives it credit. A conversion was also counted in every other row where it had a zero-credit touch, so under a single-touch model (`last_touch`, `first_touch`, `last_non_direct`) the rows (and `totals`) added up to more than `GET /api/v1/overview`; they now add up to it exactly. Under a multi-touch model a conversion split between rows still counts in each, and `credited_conversions` is the share that adds up.

### 2026-10-14

- **Added** · [#2075](https://github.com/AtribuCore/atribu/issues/2075) — Meta analytics for one WhatsApp template: `GET /api/v1/whatsapp/templates/{name}/analytics?connection_id&language&start&end` answers Meta's own sent / delivered / read, clicks per button (quick-reply and URL buttons) and cost over UTC days, as totals and a daily series (range ≤ 90 days, cached 15 minutes). The first call on a WABA with template insights off switches them on and answers `409 insights_enabling` with `retry_after_seconds` (`422 insights_unsupported` on a WhatsApp Business app / coexistence account, where Meta refuses that switch); `404 template_not_found`, `429` on a Meta throttle, `502 meta_unavailable`. Every error envelope with a `Retry-After` header now also carries `retry_after_seconds`.

### 2026-10-11

- **Added** · [#4145](https://github.com/AtribuCore/atribu/issues/4145) — New webhook event `leadgen.received` (provider `atribu`, opt-in): a Meta Instant Form submission on a Page the profile connected. `data` is the Graph lead merged over Meta's webhook value (`leadgen_id`, `form_id`, `ad_id`, `campaign_id`, `field_data`, …) plus `profile_id`; the event `id` is the `leadgen_id`. Same delivery as every partner event (HMAC, 24 h retry ladder).
- **Added** · [#2441](https://github.com/AtribuCore/atribu/issues/2441) — `POST /api/v1/campaigns/{id}/budget` raises or lowers one campaign's own (Advantage) budget, daily or lifetime, in one audited write (`campaigns:apply`, a person on the record, ±50 % of the live budget per call, undo through the conversion-sync rollback). New code `campaign_not_editable` (409). `POST /api/v1/adsets/{id}/budget` also accepts `new_lifetime_budget_minor` and answers `budget_type` / `previous_budget_minor` / `budget_minor`.

### 2026-10-10

- **Deprecated** · [#1764](https://github.com/AtribuCore/atribu/issues/1764) — The in-app chat routes are retired. `POST /api/v1/dashboard/chat` and `POST /api/v1/workspaces/{workspaceId}/top-performers/chat` answer `410 feature_retired` with `Deprecation` / `Sunset` headers until 2027-01-08, then 404. Ask the same questions from your own Claude, Claude Code or any MCP client connected to `https://mcp.atribu.app`: its tools are these same public reads.
- **Added** · [#1764](https://github.com/AtribuCore/atribu/issues/1764) — `GET /api/v1/workspaces/{workspaceId}/reports/hub` carries `capabilities` — whether this caller may use the hub's bulk schedule (`can_edit`) and bulk generate (`can_apply`), at the workspace grain those writes are checked at.
- **Added** · [#1764](https://github.com/AtribuCore/atribu/issues/1764) — `GET /api/v1/conversion-sync/data-source-options` lists the profile's connectable Meta data sources (WhatsApp, Messenger, Instagram, website) and `POST /api/v1/conversion-sync/datasets` creates or retrieves the dataset for one of them and wires it as a Conversions API destination — the two steps of the console's «Connect a data source» wizard, now published.

### 2026-10-09

- **Breaking** · [#1761](https://github.com/AtribuCore/atribu/issues/1761) — Learned values are no longer bare numbers. `top_performer_likelihood`, `score.top_performer_likelihood_calibrated`, `hazard_30d_pause`, `hazard_30d_degradation`, `fatigue_risk_tier`, `expected_lifespan_days` and `risk_rank_score` are `{ value, status: served | withheld | not_applicable, reason }`; read `value` only when `status` is `served`. A model serves only after an out-of-time evaluation whose 95% CI clears chance — none does today, so these are `withheld`. `/ads/fatigue-risk` returns `model.status = withheld` and an empty `ads` list. Rule verdicts gain `rule: { id, thresholds, inputs }` (`composite_rule`, `fatigue_state_rule`, scaling `guidance.rule`).

### 2026-10-08

- **Added** · [#1886](https://github.com/AtribuCore/atribu/issues/1886) — `POST …/data-sources/instagram/ensure` refuses an Instagram account connected through Instagram Login with `409 instagram_facebook_login_required`, before any Meta call: Meta creates Instagram datasets only for an account connected through Facebook Login for Business. `GET …/data-sources` reports it first as the instagram entry's `reason_code`.

### 2026-10-07

- **Added** · [#1886](https://github.com/AtribuCore/atribu/issues/1886) — Instagram business-messaging lane: `POST /api/v1/conversion-sync/data-sources/instagram/ensure` creates the dataset linked to the profile's Instagram professional account (`instagram_connection_required` 409 without one), and the data-sources status reports it. An event whose `click_ids` carry `messaging_channel: instagram` + `ig_sid` + `instagram_business_account_id` is exported to that dataset as `business_messaging` (Meta's Instagram `user_data`); with no Instagram dataset it goes to the website pixel as `system_generated` with those fields dropped. Every other event is exported as before.
- **Changed** · [#1885](https://github.com/AtribuCore/atribu/issues/1885) — `outcome.recorded` now delivers a healthcare-family profile's events to the app that provisioned the profile, and only to it — the webhook and `GET /api/v1/outcomes/recorded` with that app's key; every other caller still gets nothing (`403 healthcare_scope_forbidden` on the replay). That app gets the person when its grant on the profile opens contact, the webhook's usual rule, on both surfaces.
- **Added** · [#1885](https://github.com/AtribuCore/atribu/issues/1885) — `outcome.recorded` events (webhook and replay) carry `data.outcome_event_id` and `data.conversion_id` — the ids `GET /api/v1/campaigns/{id}/conversions` returns — so an event joins to the campaign credited for it. `conversion_id` is `null` while the outcome has produced no conversion.

### 2026-10-06

- **Added** · [#1880](https://github.com/AtribuCore/atribu/issues/1880) — WhatsApp templates with a media header: `header_media: { format: IMAGE | VIDEO | DOCUMENT, url }` on the template create, mutually exclusive with `header_text`. Atribu downloads the public https URL (JPEG/PNG ≤ 5 MB, MP4 ≤ 16 MB, PDF ≤ 100 MB) and uploads it to Meta as the review sample; a wrong type or unreachable URL is `422 invalid_content`, an oversized one `413 payload_too_large`. Each send carries its own media in a `header` component (`{ type: 'image', image: { link } }`), forwarded to Meta unchanged.

### 2026-10-05

- **Added** · [#1876](https://github.com/AtribuCore/atribu/issues/1876) — `platform_event_name_overrides.meta_capi_messaging` on export rules: the event name for click-to-WhatsApp (`business_messaging`) sends, one of Meta's messaging names (e.g. `LeadSubmitted`, `QualifiedLead`, `Purchase`), or `null` to keep the rule off that lane; anything else is a 422. Absent, Atribu maps by source event (`appointment_booked` → `QualifiedLead`). `meta_capi` now names the website event only. The WhatsApp test send uses `LeadSubmitted`.

### 2026-10-04

- **Fixed** · [#1869](https://github.com/AtribuCore/atribu/issues/1869) — Creating an export destination, or changing one's pixel / conversion action / connection, so that it matches another destination of the profile now answers `409 destination_conflict` (new error code) with `conflicting_destination_id`, instead of a 500. Nothing is written.
- **Added** · [#1863](https://github.com/AtribuCore/atribu/issues/1863) — `POST /api/v1/events/retractions` (`events:write`): retract an event you ingested through `POST /api/v1/events`, by the `idempotency_key` it was sent with. One transaction deletes the event, its conversions and their attribution, recomputes the customer's first-payment flags, queues the attribution recompute and records an audit entry naming your key and `reason`; the answer is counts only. Idempotent (`status: "already_retracted"`, zero counts). Refused, deleting nothing, with `409 event_already_exported` once a conversion export exists, and `409 event_linked_to_conversation` when a conversation recorded the event as its lead; an unknown key is `404 event_not_found`. Never needs the attribution add-on.
- **Changed** · [#1838](https://github.com/AtribuCore/atribu/issues/1838) — Customer-list consent v2 (`2026-10-04.customer-list.v2`) is now the current version and is required to create a list. A v2 list uploads hashed e-mail, phone, first name, last name and country. A list created under v1 keeps uploading hashed e-mail and phone only, and re-POSTing its v1 acceptance stays idempotent. A `POST` with v2 on a live list re-records the consent.

### 2026-10-03

- **Fixed** · [#1859](https://github.com/AtribuCore/atribu/issues/1859) — `PUT /api/v1/profiles/{profileId}/audience-exclusions` no longer fails with `500` on batches of a few hundred rows. If Atribu's database cannot be reached mid-request it now answers `503 database_unavailable` with `Retry-After`, never `500`. Retry the same body.

### 2026-10-01

- **Added** · [#1841](https://github.com/AtribuCore/atribu/issues/1841) — `GET /api/v1/attribution/ad-people`: distinct people with an ad-tagged Touch per month, day or ad (profile time zone), split by origin (a partner's historical import, a conversation's ad referral, a website visit, other), beside Meta's own reported messaging conversations started. Counts only, `analytics:read`.
- **Added** · [#1841](https://github.com/AtribuCore/atribu/issues/1841) — `messaging_conversations_started_7d` on each `GET /api/v1/ads/trend` row: Meta's own reported messaging conversations started per entity and day.
- **Added** · [#1838](https://github.com/AtribuCore/atribu/issues/1838) — A Meta customer list of a Platform-Safe profile's paying customers (hashed e-mail and phone only) and its 1 % lookalike: `GET|POST|DELETE /api/v1/partner/audiences/customer-list` and `POST …/lookalike`. Off for every profile until Atribu enables it. Partner apps keep customers out of every audience with `PUT /api/v1/profiles/{profileId}/audience-exclusions`.
- **Fixed** · [#1838](https://github.com/AtribuCore/atribu/issues/1838) — On a Platform-Safe profile, proposing or confirming a Conversion Sync audience now answers `409 platform_safe_audiences_disabled` before any Meta call. It could create empty audiences in Meta before.
- **Added** · [#1140](https://github.com/AtribuCore/atribu/issues/1140) — `GET /api/v1/conversion-sync/data-sources` reports, per data source (WhatsApp, website pixel), whether the Meta dataset is wired, what blocks it and a 30-day preview of what it would export. `POST …/data-sources/whatsapp/ensure` creates the WhatsApp Business Account's dataset (201, or 200 when it already exists); `…/website/ensure` attaches one of the ad account's EXISTING pixels (`selectable_pixels`) and never creates one. For an app-provisioned profile it is the only way that dataset gets created: connecting now only reports `ready`. Each data source reports `shared_with_profiles` / `warning: meta_destination_shared` when another profile already sends the same ad account or dataset to Meta. New error code `whatsapp_connection_required` (409).
- **Changed** · [#1140](https://github.com/AtribuCore/atribu/issues/1140) — A conversion declared `business_messaging` (or `chat`) without a click-to-WhatsApp click id is no longer exported as `chat`: it goes to the website pixel as `system_generated` (filter `action_source_degraded:business_messaging_to_system_generated`). No event without a `ctwa_clid` is ever sent with a messaging `action_source`. When the profile has no website pixel, such an event is not sent and the ledger now records it as `skipped` with `skip_reason: no_website_destination` instead of dropping it silently.

### 2026-09-30

- **Added** · [#1836](https://github.com/AtribuCore/atribu/issues/1836) — `learning_stage_status` (Meta's ad-set learning phase: `LEARNING`, `SUCCESS` or `FAIL`) and `campaign_objective` (the parent campaign's objective) on `GET /api/v1/ads/{id}?level=ad_set`, read from Atribu's nightly-synced copy, never live from Meta. Null at every other level.
- **Fixed** · [#1831](https://github.com/AtribuCore/atribu/issues/1831) — `api.atribu.app` now answers every operation that was served only on `www.atribu.app` (the `/api/v1/partner/*` routes, `POST /api/v1/events`, `/oauth/token`, webhooks, calendar, email, Instagram and more) instead of `404`, by forwarding the request unchanged to the same handler. `www.atribu.app` remains the canonical base URL.
- **Fixed** · [#1828](https://github.com/AtribuCore/atribu/issues/1828) — `outcome.recorded` webhooks are now enqueued in the replay's order within each delivered batch (they could be enqueued out of order inside a batch of up to 200). No stored data changed; the replay's order is unchanged.
- **Fixed** · [#1827](https://github.com/AtribuCore/atribu/issues/1827) — Campaign conversions are paginated. The response used to stop at 50 conversions without saying so; it now takes `limit` (1–200, default 50; anything else is a 400) and `cursor`, and returns `pagination: { has_next, cursor }`. Follow `cursor` while `has_next` is true to read every conversion; a page can hold fewer than `limit` when paths are long. Without a cursor you get the same first page as before.
- **Added** · [#1827](https://github.com/AtribuCore/atribu/issues/1827) — Each campaign conversion row carries `is_first_payment`: whether the payment is the customer's first, the flag behind the first-payment figures. Null when the conversion is not a payment. Visible under `campaigns:read`.
- **Breaking** · [#1825](https://github.com/AtribuCore/atribu/issues/1825) — Campaign, ad set and ad counts now include only conversions CREDITED to the entity. Under the single-touch models (`last_touch`, `first_touch`, `last_non_direct`) a conversion used to be counted under every campaign / ad set / ad its path crossed, and again in the unattributed remainder. So `outcome_count`, `outcome_count_first_payment` and `attributed_outcome_count` go DOWN for those models, `cac` / `cac_first_payment` go UP by the same ratio, and `GET /api/v1/campaigns/unattributed`'s counts go down. Rows + remainder now add up to the attributed total. Values (`outcome_value`, `roas`) do not change. Under `linear`, `time_decay` and `atribu` a conversion split across two entities still counts under both.
- **Added** · [#1825](https://github.com/AtribuCore/atribu/issues/1825) — `attributed_outcome_count` (conversions of every kind credited to the entity, refunds excluded; not cash, never a ROAS/CAC denominator) and `attributed_outcomes_by_type` (the same count split by conversion type, e.g. `{"dm_started": 89, "lead_created": 88}`) on `GET /api/v1/campaigns` at every `level`, `GET /api/v1/ads/{id}` and the `GET /api/v1/campaigns/unattributed` remainder. A lead-gen or conversational account no longer reads a campaign row of zeros.
- **Added** · [#1824](https://github.com/AtribuCore/atribu/issues/1824) — Each campaign conversion row now carries `outcome_event_id` — the id Atribu returns at ingest — so a caller that stored it can join the drill back to its own record. `conversion_id` is a different id. Nullable.

### 2026-09-29

- **Added** · [#1800](https://github.com/AtribuCore/atribu/issues/1800) — Provider outcomes for partner apps. New webhook event `outcome.recorded` (provider `atribu`, opt-in like every lifecycle event): each GoHighLevel and MercadoPago outcome recorded on a profile your app provisioned, with a stable `id`, `data.provider`, `outcome_type`, `occurred_at`, `customer_key`, `value`, and `contact` only when your app's grant carries `customers:read`. Never emitted for a healthcare-family profile. New `GET /api/v1/outcomes/recorded` (`conversions:read` or `customers:read`) replays the same events in the same order from an opaque cursor (90-day window).
- **Changed** · [#1776](https://github.com/AtribuCore/atribu/issues/1776) — `POST /api/v1/adsets/{id}/budget` refuses an ad set that is ARCHIVED or DELETED on Meta, or whose campaign is, with the new `409 adset_not_editable` (status read live, Atribu's synced status only as a fallback). Nothing is written to Meta; the refused attempt is audited.

### 2026-09-28

- **Breaking** · [#1800](https://github.com/AtribuCore/atribu/issues/1800) — CRM stage mapping for partner keys. New `GET /api/v1/connections/{provider}/stages` (`gohighlevel`; `connections:read` or `analytics:read`; `?locale=es|en`): every pipeline stage with the suggested outcome, the mapping in force (`current.source`: `suggested`, `user` or `engine_default`), whether it can be written and, at the top, `status`, `can_write` and `next_step`. Write a stage with `PATCH /api/v1/outcome-definitions/{id}` `{event_key}` (`goals:write`, entitlement-gated, idempotent). Breaking (narrow): on a `pipeline_stage` definition, `event_key` must now be one of the read's `outcomes[].key` on both that PATCH and `PUT /api/v1/profile/outcome-definitions`; anything else is `422 validation_error`.
- **Changed** · [#1800](https://github.com/AtribuCore/atribu/issues/1800) — CRM stages that are not outcomes stop producing outcome rows. A new GoHighLevel stage with no suggestion is stored as `unmapped` (it used to become `lead_created` silently). An opportunity in a stage mapped to `unmapped` or `ignored` (including existing stages mapped to `ignored`) records no outcome, so no conversion and no attribution, until the stage is mapped. Won / lost / abandoned deals are still recorded whatever their stage. No response shape changes; the visible effect is the customer journey timeline. Earlier rows are kept.
- **Added** · [#1800](https://github.com/AtribuCore/atribu/issues/1800) — Reporting currency at provisioning. `POST /api/v1/profiles` and `POST /api/v1/workspaces` accept `reporting_currency` (ISO-4217) for a new workspace; on `POST /api/v1/profiles` it defaults to the request's `currency`. With neither, the workspace takes the currency of the first ad account connected to it instead of a silent `USD`. Applied only when the call creates the workspace; change it later with `PATCH /api/v1/workspaces/{workspaceId}/reporting-currency`, which an ad-account connect never overrides.
- **Added** · [#1030](https://github.com/AtribuCore/atribu/issues/1030) — `POST /api/v1/partner/ad-referrals`: record the Meta ad a PAST click-to-message conversation came from, for a business moving to Atribu from another WhatsApp bot. Each referral becomes a touch at `occurred_at`, so later money (`closed_won`, `payment_received`) is credited to the ad, only inside the profile's attribution windows. It creates no lead and never changes one. Nothing from the call is sent to Meta: the click id stays on the touch and is not stored on the customer. The ad must be one of the profile's own Meta ads (`422 unknown_ad`). Scoping and customer rules as `POST /api/v1/partner/declared-source`; idempotent on `idempotency_key`. `POST /api/v1/partner/ad-referrals/preview` reports, read-only, how many existing conversions a batch of up to 500 would re-credit, per model.

### 2026-09-27

- **Changed** · [#1800](https://github.com/AtribuCore/atribu/issues/1800) — Connect hand-off hardening. One consent per hand-off (a double-click writes once, and the other tab normally reports the winner's outcome; confirm with the poll); a consent that stops partway on our side frees the link again after 3 minutes; a completed hand-off reports its real outcome on a second click; new `reason` `internal_error` (not settled, retry the same link). `POST /api/v1/connections/pending/{provider}/finalize` answers `409 account_connected_elsewhere` (new error code) for an account live on another profile of the workspace, keeping the parked token.

### 2026-09-26

- **Breaking** · [#1800](https://github.com/AtribuCore/atribu/issues/1800) — Partner connect hand-off. A `connect` hand-off now carries `start_url`, which goes straight to the provider's consent screen, and every ending returns your user to your `return_url` with `status` (`success`, `pending_selection`, `failed`, `cancelled`), a machine `reason` and `handoff_id`. The same `reason` lands on `result.reason`. The `/h/` page shows your app's name and logo. Breaking: a `return_url` your app is not registered for is now a `400` at the mint (it used to fail the hand-off later); a user who cancels on the provider's screen settles the hand-off `cancelled` with `user_cancelled` (was `failed` with `provider_denied`); a hand-off's bounce says `failed`/`cancelled` where it said `error`; and `handoff_unusable` is now `handoff_expired` or `handoff_used`.

### 2026-09-25

- **Changed** · [#1799](https://github.com/AtribuCore/atribu/issues/1799) — `POST /api/v1/partner/declared-source`: `customer` accepts `{ external_ref, email?, phone? }`. An unknown `external_ref` sent with an email and/or phone creates the customer from that PII and binds the ref (exactly as `POST /api/v1/events` does for `user_traits.external_id`), so answers can be backfilled for people Atribu has never seen; a ref alone still never creates one (`422 unknown_customer_ref`). `wa_id` is now accepted on healthcare-family profiles. A national id (RUT) is refused with `400 invalid_parameter`.
- **Added** · [#1782](https://github.com/AtribuCore/atribu/issues/1782) — `privacy_mode` (`standard` | `platform_safe` | `hipaa`) on `POST /api/v1/profiles` and on `PUT /api/v1/profiles/{profileId}/entitlements/atribu_attribution`. It is written before the grant widens, so a healthcare profile's first attribution key is minted without `customers:read` and `visitors:read`. Omitting it changes nothing.
- **Changed** · [#1782](https://github.com/AtribuCore/atribu/issues/1782) — Policy S5 now covers keys that already exist. When a profile becomes healthcare-family (`privacy_mode` `platform_safe` / `hipaa`, or the workspace's `is_healthcare_agency`), its live delegated keys carrying `customers:read` or `visitors:read` are revoked. Mint a replacement with `grant_type=client_credentials`; it comes back without both scopes.
- **Added** · [#1776](https://github.com/AtribuCore/atribu/issues/1776) — Campaign pause and resume. `POST /api/v1/campaigns/{id}/pause` and `/resume` change one campaign's status by its Meta id in ONE write — never a per-ad fan-out — with `affected_ads_count`, the ledger `action_id` and a ready `undo` call in the answer (`campaigns:apply`, `Idempotency-Key` required, audited). Both are idempotent on the end state (`outcome: "no_change"`). A resume after an Atribu pause is that pause's undo (`rollback_of`); ads paused on their own stay paused. New codes `campaign_not_found`, `campaign_not_pausable`, `campaign_not_resumable`. `GET /api/v1/campaigns/{id}/status` returns what a consent screen needs first — live status, the ads a pause would stop, daily budget, recent daily spend, each action's availability with a `reason`, and a `confirm_token` to derive your `Idempotency-Key` from. Shared class codes can carry `error.reason`.
- **Breaking** · [#1771](https://github.com/AtribuCore/atribu/issues/1771) — Link tokens by your own customer key. `POST /api/v1/customers/link-tokens` accepts `customer_key` (the `user_traits.external_id` you ingest) as a selector, and every result row now carries `customer_key`; a row that did not mint also carries a `reason` (`no_match`, `erased`, `no_selector`, `unknown_identifier_type`, `malformed_selector`) and a `message`. New `partner_page` recipe, and every recipe gains `requires`. Breaking for a key without `customers:read`: its rows no longer include `customer_profile_id`. `identifier_type` is now one of five values (an unknown type makes the row `invalid` instead of `not_found`), and a deployment without a token secret answers `503 link_tokens_disabled` instead of `service_unavailable`.
- **Added** · [#1770](https://github.com/AtribuCore/atribu/issues/1770) — Partner erasure by your own key. `POST /api/v1/customers/erasures` (`attribution:write`) erases one customer of the key's profile named by `customer_key`, `email`, `phone` or a pinned identifier, and answers a receipt: `erasure_id`, `request_ref`, `status`, `customer_key`, `reason`, `requested_at`, `completed_at`. Idempotent on `request_ref` (a replay answers `200` with the original receipt; the same ref for another customer is `400`). API keys only (a session gets `403 api_key_required`); never needs the attribution add-on. An unknown customer is `404 customer_not_found`, an unknown receipt `404 erasure_not_found`. Conversions stay in reports without a person; the key stops resolving and link tokens stop binding. `GET /api/v1/customers/erasures/{erasure_id}` reads the receipt. It never carries `customer_profile_id`.
- **Changed** · [#1768](https://github.com/AtribuCore/atribu/issues/1768) — By-key journey misses get their own codes. `GET /api/v1/customers/journey?customer_key=` answers `404 customer_not_found` (unknown, erased or another profile's customer, all the same) and `GET /api/v1/conversions/{id}/journey` answers `404 conversion_not_found`, instead of `not_found`, which also means a route the deployment does not serve. Same status; branch on the code.
- **Added** · [#1708](https://github.com/AtribuCore/atribu/issues/1708) — `POST /api/v1/partner/declared-source`: a partner that asks "how did you find us?" in its own WhatsApp conversation sends the interpreted answer, stored beside click attribution with `collected_via = partner`. Name the customer by exactly one of `external_ref` (your own customer id, resolved read-only — required on healthcare-family profiles) or `wa_id`. Idempotent on `wamid`, one answer per customer, first wins; an unknown `answer_key` is `422 unknown_answer_key`, an unknown `external_ref` is `422 unknown_customer_ref`. The declared-source vocabulary gains `walk_in` (walked or drove past the place).

### 2026-09-24

- **Added** · [#1766](https://github.com/AtribuCore/atribu/issues/1766) — Journey reads for keys without `customers:read`. `GET /api/v1/conversions/{id}/journey` now opens at `conversions:read`: a key without `customers:read` gets the key-only projection (touch, channel, campaign / ad set / ad by platform id, click-id kind, join method, credit, `in_window`) with `meta.conversion.customer_key` — your own customer id — and no person, page, device, geo or session fields at all; a key with it gets the same response as before. New: `GET /api/v1/customers/journey?customer_key=` returns one person's whole path by the `external_id` you ingest, with `meta.person` (counts and the newest 50 conversions); `?email=` / `?phone=` need `customers:read`. Healthcare-family profiles' keys now also leave out `visitors:read` (policy S5).
- **Added** · [#1696](https://github.com/AtribuCore/atribu/issues/1696) — Apply Atribu's URL parameters to Meta ads. `POST /api/v1/quality/ad-ids/url-tags/apply` writes them onto up to 25 of the ads `GET /api/v1/quality/ad-ids` lists, merged with each ad's own, and returns one result per ad (applied, skipped with a reason, or Meta's refusal). Every call must carry `acknowledge_learning_reset: true`: the change replaces the ad's creative (same post), which Meta treats as a significant edit. `…/undo` puts one ad back on its previous creative; `GET …/url-tags` says whether the profile can apply at all (`ads_management` + `campaigns:apply`) and lists recent changes.
- **Added** · [#1676](https://github.com/AtribuCore/atribu/issues/1676) — Partner ad operations. `POST /api/v1/ads/{id}/pause` and `/resume` pause or resume one ad by its Meta id with no recommendation behind it, and `POST /api/v1/adsets/{id}/budget` changes one ad set's daily budget (±50 % of the live budget per call, `422 budget_change_out_of_bounds` outside it) — all `campaigns:apply`, `Idempotency-Key` required, audited and reversible. A connection whose Meta grant lacks `ads_management` answers `403 meta_write_permission_missing` before anything is written. `GET /api/v1/conversions/{id}/journey` returns the visitor timeline behind one conversion, addressed by its `outcome_event_id`, `conversions.id` or your own event key.
- **Changed** · [#1676](https://github.com/AtribuCore/atribu/issues/1676) — On a key minted by the app that provisioned the profile, `applied_by` may be omitted: the apply is attributed to the workspace's owner (the user provisioning created) while it is still the active owner. An explicit `applied_by`, and every other key, behave as before.

### 2026-09-23

- **Added** · [#1670](https://github.com/AtribuCore/atribu/issues/1670) — `POST /api/v1/events` accepts an optional `action_source` in Meta's vocabulary (`website`, `business_messaging`, `physical_store`, `system_generated`, `app`, `phone_call`, `chat`, `email`, `other`). It reaches Meta verbatim on every Conversions API export of the event, in every privacy mode, and any value other than `website` ships with no `event_source_url` — even when the buyer has fbc/fbp browser signals. A `business_messaging` event with `click_ids.ctwa_clid` goes to the dataset linked to the profile's WhatsApp Business Account; without that dataset or a click id it is sent as `chat`. Omitting the field keeps the previous inference.
- **Added** · [#1670](https://github.com/AtribuCore/atribu/issues/1670) — Each deliveries ledger entry now carries `action_source`: the Meta `action_source` the stored payload carried (the declared value when there was one, else Atribu's inference). `null` for a non-Meta delivery or a row skipped before a payload was built.
- **Changed** · [#1669](https://github.com/AtribuCore/atribu/issues/1669) — Platform-Safe now sends to the profile's EXISTING Meta dataset by default. A new `platform_safe_target` (`existing` | `clean`, read on the catalog settings and on `GET /api/v1/exports/destinations`) decides which dataset gets the scrubbed feed: under `existing` the ordinary Meta CAPI destinations ship and nothing is recreated; under `clean` only the Clean Dataset ships, as before. Profiles that already had a Clean Dataset were moved to `clean`, so their routing is unchanged. A successful provision sets the target to `clean`, and its `checks` report it. Deliveries held for a leftover Clean Dataset under `existing` are skipped with `platform_safe_clean_dataset_inactive`.
- **Added** · [#1669](https://github.com/AtribuCore/atribu/issues/1669) — `POST /api/v1/conversion-sync/platform-safe/target` switches a Platform-Safe profile between its existing dataset and a Clean Dataset. `clean` provisions the Clean Dataset and lists the ad sets to recreate (the target flips only when a Clean Dataset exists); going back to `existing` answers `409 clean_dataset_has_live_ad_sets` while Clean Dataset ad sets still deliver, unless `confirm_live_ad_sets: true`. The wiring read's `platform_safe` block gains `target`, `target_destination_ids`, `event_mapping` (the event name each rule sends, with a 7-day double-count check against the dataset's own counts), `categorization` (the Events Manager checklist and the open `platform_safe_events_not_accepted` alert) and the `switch_to_clean_dataset` remediation; under `existing` it has no provision or recreate step. Clean Dataset ad sets now optimize on the dataset's standard event (`optimize_on: standard_event`), not on a custom conversion that Core Setup would silence.
- **Added** · [#1668](https://github.com/AtribuCore/atribu/issues/1668) — A partner app's DPA is accepted once, at the app. An owner or admin of the workspace that administers the app accepts it in the console (Workspace settings → Compliance), and every profile the app provisioned — and every one it provisions later — reads `dpa_status: accepted` with that version and acceptor, so `sign_dpa` no longer appears for them. Withdrawing returns those inherited profiles to `withdrawn`; a profile its own owner accepted keeps its acceptance. An API key or MCP token still cannot accept.
- **Fixed** · [#1653](https://github.com/AtribuCore/atribu/issues/1653) — An email send the mailbox's provider refuses is no longer a `502`. A refusal about the mailbox answers its own code — `403 account_suspended`, `403 mailbox_disabled`, `403 auth_revoked` (with `reconnect_required: true`) or `429 quota_exceeded` — a refused recipient answers `422 recipient_rejected`, and every one carries the provider's own words in `provider`, `provider_status`, `provider_code` and `provider_message`. The connection reports `status: "needs_action"` (or `reconnect_required` for a dead grant) with the provider's message in `status_reason`, and a `channel.health.updated` event is pushed on provider `email`; the first send the provider accepts clears it. Only a transient provider failure still answers `502`/`503`.
- **Fixed** · [#1653](https://github.com/AtribuCore/atribu/issues/1653) — Inbound email `delivery_failure` now carries the report's `status` on Gmail bounces — it was always `null` there, because Gmail nests the per-recipient fields one part deeper than Atribu read, so a permanent `5.x.x` looked like a soft bounce. It also gains `action` (`failed`, `delayed`, `delivered`, `relayed` or `expanded`) and `diagnostic_code` (the remote server's own words), both `null` when the report has none.
- **Fixed** · [#1653](https://github.com/AtribuCore/atribu/issues/1653) — An email send from an Outlook mailbox now answers a real `provider_message_id` — the Microsoft Graph immutable id of the sent message (its copy in Sent Items) — instead of `""`, for a new thread and for a reply alike. `thread_id` and `rfc822_message_id` are unchanged. Inbound Outlook events keep Graph's default-format id, so recognise your own send by `rfc822_message_id`, not by comparing ids.
- **Changed** · [#306](https://github.com/AtribuCore/atribu/issues/306) — The automated duplicate-and-swap is open to partners behind a consent contract. An API key or MCP user token that sends `confirm_recreate: true` or `confirm_duplicate_swap: true` must also send `consent: { version, text_hash, accepted_by, accepted_at }` for the current consent text (`2026-09-23.duplicate-swap.v1`, published on the Partners page); without it the call is `409 consent_required` and nothing reaches Meta. Signed-in sessions are unchanged. Both responses gain `warnings` (new ad set ids, learning reset, double spend), and error envelopes may carry `consent`.
- **Changed** · [#306](https://github.com/AtribuCore/atribu/issues/306) — Platform-Safe provisioning is ready for partners. An API key or MCP user token must switch the profile to `platform_safe` first, or gets `409 platform_safe_required`; no usable Meta Ads connection is `409 meta_connection_required`. A Clean Dataset that already covers every tracked outcome answers `status: "already_provisioned"` with no Meta call, and every 200 now carries `checks` (privacy mode, Meta connection, `ads_management`, mapped outcome types). With no Meta CAPI destination, the profile's single Meta Ads connection is used.
- **Added** · [#306](https://github.com/AtribuCore/atribu/issues/306) — The wiring read has a `platform_safe` block for Platform-Safe profiles: `platform_safe_dataset: provisioned | missing` (remediation `provision_platform_safe_dataset`), only the Clean Dataset's rows in `results`, `recreate_ad_set` instead of `wire_optimization`, and, on a live check, the ad sets to recreate with their target conversion and Ads Manager steps. It is `null` in every other mode.

### 2026-09-22

- **Added** · [#1653](https://github.com/AtribuCore/atribu/issues/1653) — An email send can now START a thread, and the response says which thread it started. `POST /api/v1/messages` with `channel: "email"` answers with `thread_id` (Gmail `threadId` / Outlook `conversationId` — the same value the inbound `message.received` event carries, so the thread you open is the thread the reply arrives on) and `rfc822_message_id` (the `Message-ID` of what was sent). Send `to` + a `subject` with no `thread_id` and a NEW thread is opened; a reply returns the thread it replied into. The email content also takes an optional `list_unsubscribe: { url, one_click }`, which writes `List-Unsubscribe: <url>` and — with `one_click: true`, which requires an `https://` url — `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058). And the inbound email event gains `delivery_failure`: `null` for an ordinary message, or `{ failed_recipient, status, original_rfc822_message_id }` parsed out of the RFC 3464 delivery-status report a bounce carries, where `original_rfc822_message_id` equals the `rfc822_message_id` your send returned. Everything here is optional and additive — an existing consumer's request and response are unchanged.

### 2026-09-15

- **Added** · [#1517](https://github.com/AtribuCore/atribu/issues/1517) — The three message-send routes now honour an optional `Idempotency-Key` header, so a send lost to a timeout or a 5xx can be retried without delivering twice. A repeat within 24 hours replays the first response verbatim and sends nothing; scope is your API key + the route + the key value. A repeat that arrives while the first send is still in flight answers `409 idempotency_key_in_flight` (a new error code — retry the same key with backoff) rather than risking a second delivery, and a repeat carrying a different body answers `409 idempotency_key_conflict`. Only a 2xx is stored, so a send that genuinely failed can be retried under the same key. Omit the header and nothing changes.

### 2026-09-14

- **Added** · [#1420](https://github.com/AtribuCore/atribu/issues/1420) — `GET /api/v1/partner/whatsapp/ads` lists a profile's click-to-WhatsApp ads with their destination (type, WhatsApp number, Page), call-to-action type, greeting and `greeting_status`, paginated by keyset on `ad_id`. Served from the nightly Meta sync, which now reads that configuration for every CTWA ad — so `/partner/whatsapp/ads/{ad_id}/welcome-message` answers for a synced ad without a Graph call, and still falls back to a live read for one launched since the last sync. No spend, no metrics, no PII.

### 2026-09-11

- **Added** · [#1402](https://github.com/AtribuCore/atribu/issues/1402) — `GET /api/v1/partner/whatsapp/ads/{ad_id}/welcome-message` reads the welcome message ("Saludo automático") a click-to-WhatsApp ad shows a customer BEFORE their first message — the greeting, the pre-filled text and the ice-breaker buttons. Meta never delivers it over the webhook. `welcome: null` means the ad has none configured; `404` means Meta does not show us the ad, `409` that the profile has no usable Meta Ads connection.

### 2026-09-07

- **Added** · [#452](https://github.com/AtribuCore/atribu/issues/452) — Per-consumer usage: request counts, the 4xx/5xx split and p50/p95 latency, per API key for a workspace and per registered app for Atribu ops. Every rate-limited response now also carries `X-RateLimit-Reset`.
- **Changed** · [#452](https://github.com/AtribuCore/atribu/issues/452) — A registered consumer app now has a rate TIER (`standard` 300/min, `elevated` 1200/min, `internal` 6000/min) that sets its per-minute allowance on client-credential requests. An `atb_live_` key keeps its own `rate_limit_per_minute` — the two meter different principals and are not merged.

### 2026-09-06

- **Added** · [#1209](https://github.com/AtribuCore/atribu/issues/1209) — `GET /api/v1/openapi.json` is served from the API host itself, so the spec and the API it describes cannot come from different deploys.
- **Added** · [#1190](https://github.com/AtribuCore/atribu/issues/1190) — `DELETE /api/v1/workspaces/{workspaceId}` archives a workspace, driven by membership.
- **Changed** · [#1185](https://github.com/AtribuCore/atribu/issues/1185) — An OAuth app's empty `allowed_return_origins` no longer means "no bounce": the effective allowlist derives from its own `redirect_uris`. `return_url_supported` on `GET /api/v1/me`'s app-credential branch reports the effective set, and the admin routes return `effective_return_origins`.

### 2026-09-05

- **Breaking** · [#1159](https://github.com/AtribuCore/atribu/issues/1159) — Two new scopes, `workspaces:write` and `profiles:write`, both granted by `mcp:write`. An MCP user token minted with the default `mcp:read` grant could create workspaces and profiles; it now gets `403 insufficient_scope` and nothing is created on the way to that refusal. Sessions and app credentials are unchanged.
- **Breaking** · [#1154](https://github.com/AtribuCore/atribu/issues/1154) — A webhook subscription URL must point at a public host; a private or non-routable target is refused at registration AND again at delivery time, and redirects are no longer followed.
- **Added** · [#1109](https://github.com/AtribuCore/atribu/issues/1109) — An agent can start a provider connect and hand the human a session-less URL. Stripe and MercadoPago join Meta Ads, Google Ads, Google Search Console and GoHighLevel on the shared `prepareConnectStart` path.
- **Added** · [#1099](https://github.com/AtribuCore/atribu/issues/1099) — Every operation in the published spec carries structured `x-atribu-scopes`, `x-atribu-auth`, `x-atribu-idempotency` and `x-atribu-scope-grain` extensions, plus a response example per tag.
- **Added** · [#1089](https://github.com/AtribuCore/atribu/issues/1089) — Every error envelope now carries `docs_url`, pointing at that exact code's section of the generated errors reference.
- **Deprecated** · [#1086](https://github.com/AtribuCore/atribu/issues/1086) — `POST /api/v1/goals` is deprecated in favour of `POST /api/v1/goals/definitions`, which can express what the older collection cannot (updates, an explicit `conversion_key`, `lookback_window_days`, `is_default`).

### 2026-09-03

- **Added** · [#1000](https://github.com/AtribuCore/atribu/issues/1000) — `GET /api/v1/workspaces/{workspaceId}/pii-access-log` — who read this workspace's customer/visitor personal data, when, through which route and with what result. Durable for 365 days.
- **Changed** · [#502](https://github.com/AtribuCore/atribu/issues/502) — `DELETE /api/v1/connections/{id}` now also revokes key generations older than the authorization that names them, when the authorization revoked is the app's last live one on that profile. `revoked_keys` can exceed the number of keys you minted under it.

### 2026-09-02

- **Added** · [#533](https://github.com/AtribuCore/atribu/issues/533) — `POST /api/v1/ads/{id}/creative-analysis` queues an on-demand creative analysis at the new `creatives:write` scope. It never runs a model on the request; it answers `202` and the `GET` on the same path is the poll.
- **Added** · [#391](https://github.com/AtribuCore/atribu/issues/391) — Two commerce reads at the new `commerce:read` scope: a connected store's catalogue (an ascending change feed you walk with `updated_since`) and an order lookup by number, email or phone. The order row carries no customer identity.

### 2026-09-01

- **Breaking** · [#898](https://github.com/AtribuCore/atribu/issues/898) — `GET /api/v1/overview` answers `spend: null` and `roas: null` — not `"0"` — when spend is unmeasurable for the requested filters. A measured zero is still `"0"`. Guard the denominator; do not `?? 0` it.
- **Added** · [#675](https://github.com/AtribuCore/atribu/issues/675) — Eight classification writes at `goals:write`. Every one of them changes the PAST: each queues a full-profile replay, so read `replay_queued` on the response — `false` means the change is saved but existing sessions keep their old channels until a replay runs.

---

# Workspaces & Profiles

A **workspace** is the top-level container: billing, members, branding, API
keys. A **profile** is one advertised business inside it — one set of ad
accounts, one CRM, one tracker, one attribution answer. Every profile-scoped
route on this API answers *for one profile*.

Most businesses need one workspace and one profile. An agency needs one
workspace and a profile per client.

## Creating them without a browser

```http title="Create a workspace"
POST /api/v1/workspaces
```

**Two unrelated ways to call it, sharing one URL** — the same shape
`POST /api/v1/profiles` uses.

- **Session bearer** — a Supabase session token **or an `atb_user_…` MCP
  token**: you create your own workspace and become its owner. Body:
  `{"name": "Acme", "timezone": "America/Santiago", "plan": "growth"}`.
- **HTTP Basic** — a registered consumer app creating a bare workspace for one
  of *its* users, identified by `email`. No profile and no delegation grant are
  created; follow with your own `POST /api/v1/profiles`.

`plan` defaults to `starter` (Free). `growth` / `agency` immediately write a
`trialing` subscription at that plan's real limits rather than waiting for the
first profile to bootstrap Free — so an agent that creates the workspace before
its first `POST /profiles` does not silently sit on Free believing it asked for
Growth. `enterprise` is sales-led and is not accepted here.

<Callout type="info" title="Safe to retry — the session branch is idempotent on (caller, name)">
A retried call with the same `name` inside a short window returns the **same**
workspace with `created: false` and `200`, so a dropped connection never leaves
two workspaces behind. Minutes later, the same `name` is a genuinely new
workspace — a name is legitimately reusable. The app-credential branch has no
idempotency at all: every call creates, and `created` is always `true`.
</Callout>

### `checkout` — the plan you asked for, paid for in the same round trip

A `growth` or `agency` create returns a **checkout hand-off** alongside the new
workspace, so an agent that knows which plan it wants never mints one
separately:

```json title="201 Created — plan: \"growth\""
{
  "data": {
    "workspace_id": "…",
    "name": "Acme",
    "created": true,
    "checkout": {
      "id": "0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21",
      "url": "https://www.atribu.app/h/9tQ2mB1x…",
      "expires_at": "2026-09-05T11:30:00.000Z"
    }
  }
}
```

Hand `checkout.url` to the person who pays and poll
[`GET /api/v1/handoffs/{id}`](/docs/api/handoffs) — it is an ordinary
`checkout` hand-off, the same object
`POST /api/v1/workspaces/{workspaceId}/checkout-session` mints. See
[Billing](/docs/api/billing).

<Callout type="warn" title="Workspace creation is never blocked on billing">
`checkout` is `null` for a `starter` request, and **also** `null` when the mint
itself failed — unconfigured billing, or Stripe refusing — in which case the
response still answers `201` and carries
`warnings: [{"code": "checkout_unavailable", "message": "…"}]`. The workspace
exists and `created` is still `true`: a payment link that could not be produced
must not cost you the workspace you just made. Read `warnings`, then retry the
mint alone with `POST /api/v1/workspaces/{workspaceId}/checkout-session`.

Two shapes to parse correctly: **`warnings` is omitted, not an empty array**,
when nothing degraded — and **an idempotent replay never mints a second
checkout**, so the `created: false` response carries `checkout: null` even for a
`growth` request. Capture `checkout` from the call that actually created the
workspace. `checkout` is always `null` on the app-credential branch.
</Callout>

```http title="Create a profile"
POST /api/v1/profiles
```

`POST /api/v1/profiles` has two branches. The **app-credential** branch
provisions a dealer end to end (see [Partners & OAuth apps](/docs/api/partners));
the **session** branch is what a person or their agent uses for their own next
client. A profile creation is refused with `plan_limit_reached` once the
workspace's `active_profiles` allowance is spent — that limit is enforced on
every creation path, so an agent that plans to create ten profiles should read
the subscription first.

## Retiring a workspace

```http title="Archive a workspace"
DELETE /api/v1/workspaces/{workspaceId}
```

**Owner only, and terminal — there is no un-archive.** A workspace created by
mistake, or a throwaway one an agent made while exploring, is retired with this
call.

**"Delete" means ARCHIVE, and the response says so.** A workspace that has ever
exported holds `conversion_exports` rows, and a database trigger refuses to let
any path erase them — they are the record of what Atribu transmitted on your
customers' behalf. So the call stamps `archived_at`, removes every membership,
revokes pending invitations, archives every profile and purges its tracking,
attribution and spend data, revokes the workspace's API keys and pauses its
webhook subscriptions. What survived is in `counts.retained_*`.

```json title="200 OK"
{
  "data": {
    "workspace_id": "873efefe-5fe0-44d5-8f67-0d3967855ff6",
    "archived": true,
    "archived_at": "2026-09-06T14:42:11.547Z",
    "counts": {
      "profiles_archived": 1,
      "memberships_removed": 1,
      "invitations_revoked": 0,
      "api_keys_revoked": 0,
      "webhook_subscriptions_paused": 0,
      "retained_conversion_exports": 1,
      "retained_signal_audit_rows": 3
    },
    "profiles_purged": 1
  },
  "meta": { "workspace_id": "873efefe-5fe0-44d5-8f67-0d3967855ff6" }
}
```

<Callout type="info" title="Safe to retry — idempotent, and both answers are 200">
The first call returns `archived: true`. Every later call returns
`archived: false` with the **original** `archived_at` and changes nothing. A
`DELETE` that 404s an already-archived workspace would make a retry look like a
failure.
</Callout>

A session bearer, or an `atb_user_…` token whose grant covers
`workspaces:write` — a default `mcp:read` token is refused. An `atb_live_` API
key cannot call it: a key is minted for exactly one profile and belongs to no
workspace. A **member who is not an owner** gets `403`; a **non-member** gets
`404`, the same answer an unknown id gets, so the status code never confirms
that a workspace exists.

After archiving, every workspace-scoped route answers `404` for it, it is absent
from `GET /api/v1/workspaces` and from MCP `whoami`, and a pending invitation to
it can no longer be accepted.

## Reading what you can reach

`GET /api/v1/workspaces` is the right **first call** for a user credential: it
needs no `profile_id`, and it tells you which workspaces the person behind the
token belongs to. `GET /api/v1/workspaces/{workspaceId}/profiles` then lists
the profiles inside one.

<Callout type="warn" title="A user token must name a profile">
An API key *is* one profile, so profile-scoped routes need no parameter. An
`atb_user_…` token may reach several, so `?profile_id=<uuid>` is **required**
there — its absence is a `400`, never a guess. See
[Authentication](/docs/api/authentication).
</Callout>

## Everything under `/workspaces`

The workspace is also the grain for cross-profile analytics (Top Performers,
creative patterns, experiments, pacing), for agency reporting, and for the
admin surfaces a console needs. Those routes live under `/workspaces/{id}/…`
because they are workspace-scoped, and they are explained where the feature is:
[Top Performers and creative reads](/docs/api/campaigns),
[reports](/docs/api/reports), [credentials](/docs/api/credentials).

{/* BEGIN GENERATED ENDPOINT TABLE — generated by scripts/docs/generate-api-docs.ts from openapi.json. Do not hand-edit between these markers; run `npx tsx scripts/docs/generate-api-docs.ts`. */}

| Method | Path | What it does |
|---|---|---|
| `POST` | `/api/v1/invitations/accept` | Accept a workspace or profile invitation |
| `GET` | `/api/v1/profiles` | List the profiles the signed-in user can reach in this workspace |
| `POST` | `/api/v1/profiles` | Provision a dealer (app-authenticated) |
| `DELETE` | `/api/v1/profiles/{profileId}` | Archive a profile you provisioned (app-authenticated) |
| `POST` | `/api/v1/profiles/{profileId}/adopt` | Adopt an existing profile (app-authenticated) |
| `PUT` | `/api/v1/profiles/{profileId}/audience-exclusions` | Keep customers out of every audience |
| `PUT` | `/api/v1/profiles/{profileId}/entitlements/{feature}` | Set a profile's Atribu entitlement (app-authenticated) |
| `POST` | `/api/v1/profiles/demo` | Create and seed this workspace's demo profile |
| `DELETE` | `/api/v1/profiles/demo` | Delete this workspace's demo profile |
| `GET` | `/api/v1/workspaces` | List the workspaces the signed-in user belongs to |
| `POST` | `/api/v1/workspaces` | Create a workspace |
| `DELETE` | `/api/v1/workspaces/{workspaceId}` | Archive a workspace |
| `GET` | `/api/v1/workspaces/{workspaceId}/api-keys` | A profile's API keys |
| `POST` | `/api/v1/workspaces/{workspaceId}/api-keys` | Mint a new API key for a profile |
| `POST` | `/api/v1/workspaces/{workspaceId}/api-keys/{id}/revoke` | Revoke one API key |
| `POST` | `/api/v1/workspaces/{workspaceId}/api-keys/{id}/rotate` | Rotate one API key |
| `GET` | `/api/v1/workspaces/{workspaceId}/api-usage` | Per-key API usage for a workspace |
| `GET` | `/api/v1/workspaces/{workspaceId}/audit` | List recorded changes to a workspace's classification settings |
| `POST` | `/api/v1/workspaces/{workspaceId}/billing-portal-session` | Open Stripe's Customer Portal for a workspace |
| `GET` | `/api/v1/workspaces/{workspaceId}/branding` | The workspace's report/email branding |
| `PUT` | `/api/v1/workspaces/{workspaceId}/branding` | Update the workspace's branding (non-logo fields) |
| `POST` | `/api/v1/workspaces/{workspaceId}/branding/logo` | Upload the workspace's logo |
| `DELETE` | `/api/v1/workspaces/{workspaceId}/branding/logo` | Remove the workspace's logo |
| `POST` | `/api/v1/workspaces/{workspaceId}/checkout-session` | Mint a Stripe Checkout hand-off for a plan upgrade |
| `GET` | `/api/v1/workspaces/{workspaceId}/compliance-overview` | Per-profile privacy/HIPAA compliance posture |
| `PATCH` | `/api/v1/workspaces/{workspaceId}/compliance-overview` | Set whether this workspace is a healthcare agency |
| `GET` | `/api/v1/workspaces/{workspaceId}/connected-apps` | List OAuth-app authorizations across the workspace's profiles |
| `DELETE` | `/api/v1/workspaces/{workspaceId}/connected-apps/{id}` | Revoke an OAuth-app authorization |
| `GET` | `/api/v1/workspaces/{workspaceId}/creative-archetypes` | The workspace's creative archetypes and how often each one wins |
| `GET` | `/api/v1/workspaces/{workspaceId}/creative-patterns` | Cross-profile creative patterns, and who is not running them |
| `GET` | `/api/v1/workspaces/{workspaceId}/creative-patterns/dimensions` | Which single creative-dimension values win in this workspace |
| `POST` | `/api/v1/workspaces/{workspaceId}/customer-erasures` | Erase a customer's personal data |
| `GET` | `/api/v1/workspaces/{workspaceId}/experiments` | Meta lift and split studies across the workspace's profiles |
| `GET` | `/api/v1/workspaces/{workspaceId}/invitations` | List pending workspace invitations |
| `POST` | `/api/v1/workspaces/{workspaceId}/invitations` | Invite someone to the workspace |
| `DELETE` | `/api/v1/workspaces/{workspaceId}/invitations/{invitationId}` | Revoke a pending workspace invitation |
| `GET` | `/api/v1/workspaces/{workspaceId}/mcp-settings` | Get the workspace's MCP data-plane settings |
| `PUT` | `/api/v1/workspaces/{workspaceId}/mcp-settings` | Update the workspace's MCP data-plane settings |
| `GET` | `/api/v1/workspaces/{workspaceId}/mcp-writeback-audits` | List the workspace's MCP write-back audit log |
| `GET` | `/api/v1/workspaces/{workspaceId}/members` | List a provisioned workspace's members (app-authenticated) |
| `PUT` | `/api/v1/workspaces/{workspaceId}/members` | Add or update a workspace member (app-authenticated) |
| `DELETE` | `/api/v1/workspaces/{workspaceId}/members/{userId}` | Remove a workspace member (app-authenticated) |
| `GET` | `/api/v1/workspaces/{workspaceId}/oauth-apps` | OAuth apps this workspace administers, with their DPA state |
| `POST` | `/api/v1/workspaces/{workspaceId}/oauth-apps/{appId}/dpa/accept` | Accept the DPA for every profile an OAuth app provisions |
| `POST` | `/api/v1/workspaces/{workspaceId}/oauth-apps/{appId}/dpa/withdraw` | Withdraw an OAuth app's DPA acceptance |
| `GET` | `/api/v1/workspaces/{workspaceId}/pacing` | Month-to-date spend and outcomes against budget, per profile |
| `GET` | `/api/v1/workspaces/{workspaceId}/pii-access-log` | Audit access to this workspace's customer/visitor PII |
| `GET` | `/api/v1/workspaces/{workspaceId}/profiles` | List the profiles the signed-in user can open in a workspace |
| `GET` | `/api/v1/workspaces/{workspaceId}/profiles/{profileId}/invitations` | Pending invitations to one profile |
| `POST` | `/api/v1/workspaces/{workspaceId}/profiles/{profileId}/invitations` | Invite someone to one profile |
| `DELETE` | `/api/v1/workspaces/{workspaceId}/profiles/{profileId}/invitations/{invitationId}` | Revoke a pending profile invitation |
| `PATCH` | `/api/v1/workspaces/{workspaceId}/profiles/{profileId}/members/{membershipId}` | Change a profile member's role or permissions |
| `DELETE` | `/api/v1/workspaces/{workspaceId}/profiles/{profileId}/members/{membershipId}` | Remove someone from a profile |
| `GET` | `/api/v1/workspaces/{workspaceId}/recommendations` | Open recommendations across every profile in the workspace |
| `GET` | `/api/v1/workspaces/{workspaceId}/reporting-currency` | The currency this workspace's cross-profile totals are reported in |
| `PATCH` | `/api/v1/workspaces/{workspaceId}/reporting-currency` | Set the currency this workspace's cross-profile totals are reported in |
| `POST` | `/api/v1/workspaces/{workspaceId}/reports/bulk-generate` | Generate (and optionally send) a report across many profiles |
| `GET` | `/api/v1/workspaces/{workspaceId}/reports/hub` | The workspace Reports hub |
| `POST` | `/api/v1/workspaces/{workspaceId}/reports/schedules/bulk` | Apply a schedule patch across many profiles |
| `GET` | `/api/v1/workspaces/{workspaceId}/sender-domain` | The workspace's verified sending domain, if any |
| `POST` | `/api/v1/workspaces/{workspaceId}/sender-domain` | Register a sending domain for this workspace |
| `DELETE` | `/api/v1/workspaces/{workspaceId}/sender-domain` | Remove the workspace's sending domain |
| `POST` | `/api/v1/workspaces/{workspaceId}/sender-domain/verify` | Re-check DNS verification for the workspace's sending domain |
| `POST` | `/api/v1/workspaces/{workspaceId}/shopify-plan-selection` | Choose a plan on a Shopify App Store workspace |
| `GET` | `/api/v1/workspaces/{workspaceId}/snapshots` | List shared Top Performers snapshot links |
| `POST` | `/api/v1/workspaces/{workspaceId}/snapshots` | Capture a shareable Top Performers snapshot |
| `DELETE` | `/api/v1/workspaces/{workspaceId}/snapshots/{snapshotId}` | Revoke a shared snapshot link early |
| `GET` | `/api/v1/workspaces/{workspaceId}/subscription` | The workspace's current subscription — plan, status, period bounds |
| `GET` | `/api/v1/workspaces/{workspaceId}/tab-counts` | How much is new on each console tab since you last opened it |
| `GET` | `/api/v1/workspaces/{workspaceId}/top-performers` | The workspace's top-performing ads, across every profile |
| `GET` | `/api/v1/workspaces/{workspaceId}/top-performers/brief` | The six counters that summarise a workspace's leaderboard |
| `POST` | `/api/v1/workspaces/{workspaceId}/top-performers/chat` | Retired: the Top Performers assistant |
| `POST` | `/api/v1/workspaces/{workspaceId}/top-performers/export` | Export top ads as a bundle for a coding agent |

Generated from `openapi.json`. The full request and response schema for every operation is in the [OpenAPI document](https://www.atribu.app/api/v1/openapi.json).

{/* END GENERATED ENDPOINT TABLE */}

## Next steps

<Cards>
  <Card title="Step 3 — Workspace and profile" href="/docs/getting-started/workspace-and-profile">
    The golden path's own page for this step, both tracks
  </Card>
  <Card title="Credentials" href="/docs/api/credentials">
    API keys, MCP tokens, and which principal can do what
  </Card>
  <Card title="Connections" href="/docs/api/connections">
    The next step: Meta, Google, the CRM, the payment provider
  </Card>
</Cards>

---

# Demo profile

```http title="Endpoints"
POST   /api/v1/profiles/demo?workspace_id={workspace_id}
DELETE /api/v1/profiles/demo?workspace_id={workspace_id}
```

**Scope:** `analytics:read`, plus an ACTIVE **owner** or **admin** membership on
the workspace. Session bearers and user tokens only.

One call gives a workspace something to read before any live account is
connected: a profile flagged `is_demo`, sixty days of one fictional clinic's
Meta traffic, and a Meta CAPI destination that **records what it would have
sent** instead of sending it.

It exists so that the rest of this reference has something to run against. Every
`curl` on the Overview, Timeseries, Breakdowns, Campaigns, Conversions and
Conversion Sync pages answers with real numbers on a demo profile, on the first
day, with no Meta, GHL or Stripe account connected.

## The engine runs for real

The seed writes the pipeline's **inputs** — ad structure, daily spend,
identities, marketing touches, and outcome events through the same CRM-ingest
primitive every real provider uses — and then enqueues an ordinary attribution
recompute.

`conversions` and the credit assigned across all five attribution models come
out of the pipeline, not out of the seed. Nothing here is precomputed and
nothing is faked downstream of ingest.

That is also why `seeded.conversions` in the response is what the recompute
**will** produce: it runs asynchronously. Poll `GET /api/v1/overview` until the
numbers appear — typically seconds.

## What is seeded

| | |
|---|---|
| Ads | 3 campaigns × 2 ad sets × 8 ads, as `ad_entity_dim` rows |
| Spend | one row per ad per day, 60 days — so ROAS has a denominator |
| People | ~40 customers, with unroutable `@demo.invalid` emails and reserved-block phone numbers |
| Touches | paid Meta clicks (with `fbclid` and UTMs following Atribu's own convention) plus organic and direct visits |
| Outcomes | `lead_created` → `appointment_booked` → `payment_received`, plus one refund so cash netting is visible |
| Definitions | the standard `conversion_definitions` set, seeded by the database exactly as for a real profile |
| Exports | one Meta CAPI destination and one enabled signal rule on the cash definition |

The dataset is **deterministic**: the same workspace seeded twice produces the
same numbers.

## Exports record; they never leave

The destination is an ordinary `tracking_export_destinations` row over an
ordinary connection, with a placeholder pixel id and a placeholder token. Nothing
about it is a special "recording destination" type — the candidate builder, the
cross-path dedup, the privacy and legal gates and the payload build all run
unmodified.

Recording is a property of the **profile**. In the export worker, an export whose
profile is `is_demo` is written to `conversion_exports` with the exact payload
Meta would have received and is never posted:

```json title="conversion_exports"
{
  "status": "skipped",
  "skip_reason": "demo_profile_recorded",
  "payload": { "data": [{ "event_name": "Purchase", "event_id": "…", "user_data": { … } }] }
}
```

`skipped` rather than a new `recorded` status because the status column is a
closed set and the honest word for "this was never sent" is already in it. The
reason column is what carries *why*.

<Callout type="warn">
A demo profile can never reach Meta, even if someone edits its destination to a
real pixel id. The suppression is on the profile, at the last moment before the
network call.
</Callout>

## What the demo is excluded from

Reads scoped **to** the demo profile show its money. That is the point — an
empty demo would demonstrate nothing.

Every read that spans the profiles of a workspace leaves it out:

- the workspace pacing aggregate;
- Top Performers cohorts and the `creative_feature_store` rebuild (a demo
  profile is never scored, so it can never appear in a leaderboard or move a
  real ad's percentile);
- the agency Reports hub;
- alert digest emails;
- the ML ad-fact and creative-lifecycle passes;
- the `active_profiles` plan limit — a demo consumes **no seat**, and creating
  one is not gated by the limit.

## Create

```bash title="Request"
curl -X POST "https://api.atribu.app/api/v1/profiles/demo?workspace_id=$WORKSPACE_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"currency":"USD","timezone":"America/Santiago"}'
```

Both body fields are optional; an empty body is fine.

```json title="201 Created"
{
  "data": {
    "profile": {
      "id": "…",
      "name": "Demo — Acme Agency",
      "workspace_id": "…",
      "currency": "USD",
      "is_demo": true
    },
    "seeded": {
      "touches": 103,
      "outcome_events": 79,
      "conversions": 79,
      "spend_days": 60,
      "definitions": 12
    },
    "destination": { "id": "…", "mode": "recording" },
    "created": true,
    "warnings": []
  },
  "meta": { "workspace_id": "…" }
}
```

**Idempotent.** A workspace holds at most one demo profile. Calling this again
re-seeds the existing one and answers `200` with `created: false`; only the first
call answers `201`. `warnings` is empty on a clean run and names anything that
did not land — the seed never half-fails silently.

## Delete

```bash title="Request"
curl -X DELETE "https://api.atribu.app/api/v1/profiles/demo?workspace_id=$WORKSPACE_ID" \
  -H "Authorization: Bearer $TOKEN"
```

```json title="200 OK"
{
  "data": {
    "deleted": true,
    "profile_id": "…",
    "archived": true,
    "retained_audit_rows": 4
  },
  "meta": { "workspace_id": "…" }
}
```

**Delete means archive + purge, and audit rows are retained.** The seeded data —
touches, outcome events, conversions, attribution, spend, the ad structure, the
customers and the conversion definitions — is deleted, and the profile is
archived rather than removed.

`conversion_exports` is the record of what Atribu transmitted (or, for a demo,
would have transmitted) on your behalf. A database trigger refuses to let any
user-facing path erase it, so those rows — and the handful their foreign keys pin
in place: the exported outcome event, its customer, the destination and the rule
— stay attached to the archived profile. `retained_audit_rows` counts them.

The archived profile disappears from every read (`archived_at IS NULL` is the
filter the whole product already applies) and **releases the workspace's demo
slot**, so `POST /api/v1/profiles/demo` succeeds immediately afterwards.

Deleting nothing answers `200` with `deleted: false`, not `404`, so a retry never
looks like a failure. A `409 demo_purge_blocked` means a trigger refused part of
the purge; the profile is archived regardless and retrying changes nothing.

## Telling a demo apart

`is_demo` is on `GET /api/v1/profile` and on every row of
`GET /api/v1/profiles`. Read the flag — never the name, which anyone may change.

## Errors

| status | when |
|---|---|
| `400 invalid_request` | missing or malformed `workspace_id`, or a malformed body |
| `400 validation_error` | an unknown body field, or a `currency` that is not a 3-letter code |
| `403 insufficient_scope` | the credential is an API key — it names one profile and belongs to no workspace |
| `404 not_found` | the workspace does not exist, or you are not an ACTIVE owner/admin member |

## SDK

```ts
const demo = await atribu.profile.createDemo(workspaceId);
// … poll atribu.analytics.overview({ from, to }) until the numbers land
await atribu.profile.deleteDemo(workspaceId);
```

---

# Connections

A **connection** is a profile's live link to an ad platform, a CRM, a payment
provider or a store: the thing that makes campaigns, conversions and revenue
appear in every other endpoint. Reading them is a normal API call. Creating one
is not — the provider's consent screen needs a person in a browser, which is
what this page is mostly about.

## Read what a profile is connected to

```http title="Endpoints"
GET    /api/v1/connections
GET    /api/v1/connections/{id}
DELETE /api/v1/connections/{id}
```

`GET /api/v1/connections` is the durable answer to "is this connected, and is it
healthy". Each row carries `channel` (the provider), `status`, and a `sync`
object with the last successful run and the provider's own error text when the
last one failed. There is no separate health endpoint — poll this, narrowed with
`?channel=` when only one matters.

```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/connections?channel=meta_ads"
```

## Find the pixel a CAPI destination needs

```http title="Endpoint"
GET /api/v1/connections/{id}/pixels
```

**Scope:** `exports:read` — part of the `attribution` grant, the same one that
carries `exports:write` for creating the destination itself.

`POST /api/v1/exports/destinations` requires a `meta_pixel_id`, and until this
endpoint there was no way for a key to learn one: the dataset read only
reconciles a dataset you have **already** configured, and the setup-link mint is
for a signed-in human and refuses API keys. So a headless connect ended by
asking the account owner to copy an id out of Events Manager.

```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/connections/$CONNECTION_ID/pixels"
```

```json title="Response"
{
  "data": {
    "pixels": [
      {
        "id": "1234567890123456",
        "name": "Dealer Santiago — Web",
        "created_at": "2025-04-02T11:20:15-0700",
        "last_fired_at": "2026-09-14T08:41:02-0700",
        "is_unavailable": false,
        "owner_business": { "id": "998877665544332", "name": "Grupo Dealer" }
      }
    ]
  },
  "meta": { "profile_id": "3f1c9d2e-7a64-4a6f-9d58-0b3a2c1e4d55" }
}
```

`id` is the only field Meta always returns. **Everywhere else, `null` means Meta
did not say — not `false` and not zero.** A `null` `last_fired_at` is usually a
pixel that has never fired, which is what tells you which of several to pick; a
`null` `is_unavailable` is a pixel Meta gave no verdict on, so do not render it
as healthy.

The `id` your caller must take is the connection's, not the ad account's: a
connection that is not `meta_ads` answers `409`, and one on another profile
answers the same `404` an unknown id does. One live Meta call per request, never
cached — you are about to write a destination against the answer.

## Start a connect and hand it to a human

```http title="Endpoint"
POST /api/v1/connections/{provider}/handoff
```

**Scope:** `connections:write` — every write on this rail takes it
(`pending`/`finalize`, `disconnect`, `sync`); the one read,
`GET …/sync/status`, takes `connections:read` instead. A Partner App gets
both through the connectionless `connections` OAuth scope; see
[Connect broker vs. the attribution Add-on](/docs/api/partners#connect-broker-vs-the-attribution-add-on-1455)
for how that scope is minted, and widened into the full attribution Add-on.
**Since #1455:** a key minted under an older `attribution_write` grant is
still accepted here during the swap window, until the consumer re-mints
against `connections`.

Your agent cannot grant an OAuth consent. Meta, Google and GoHighLevel put a
permissions screen in front of a logged-in person, and no API key gets past it.
So instead of failing, mint a **hand-off**: you get a URL, you give it to your
user, and you poll until they are done.

```js title="JavaScript"
import { AtribuClient } from "@atribu/node";

const client = new AtribuClient({ apiKey: process.env.ATRIBU_API_KEY });

// 1. Mint. No body needed — the provider and your key's profile are enough.
const { data: handoff } = await client.connections.handoff("meta_ads");

// 2. Hand the URL over, however you already talk to your user.
console.log("Ask them to open:", handoff.url);

// 3. Poll.
const { data: state } = await client.handoffs.get(handoff.id);
if (state.status === "completed") {
  console.log("connected:", state.result?.connection_id);
}
```

```bash title="cURL"
curl -X POST \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{}' \
  "https://api.atribu.app/api/v1/connections/meta_ads/handoff"
```

The response is a [hand-off](/docs/api/handoffs) — the same object
`GET /api/v1/handoffs/{id}` returns, so your mint response and your poll
response are one shape and you need one parser.

```json title="Success response (200 OK)"
{
  "data": {
    "id": "0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21",
    "kind": "connect",
    "status": "pending",
    "url": "https://www.atribu.app/h/9tQ2mB1xQhK4vT7nJ0pL9sD3fG6hZ5cW2aY1bN4uM8E",
    "start_url": "https://www.atribu.app/api/integrations/meta/oauth/start?handoff_handle=9tQ2mB1xQhK4vT7nJ0pL9sD3fG6hZ5cW2aY1bN4uM8E",
    "expires_at": "2026-09-05T11:30:00.000Z",
    "created_at": "2026-09-05T10:45:00.000Z",
    "completed_at": null,
    "result": null
  },
  "meta": { "profile_id": "8f3d…" }
}
```

### Which providers

`meta_ads` · `google_ads` · `google_search_console` · `gohighlevel` · `stripe` ·
`mercadopago` · `shopify`

These are the `connection_provider` values `GET /api/v1/connections` reports as
`channel`, so a connection you read back can be fed straight into a re-connect
with no translation. Anything else is a `400`.

**`shopify` needs the store.** Send `shop` in the body — the store's
`<handle>.myshopify.com`, the bare handle, or a pasted admin URL (a custom
storefront domain is a `400`: Shopify's install runs on the myshopify domain).
`start_url` then goes straight to that store's install screen for Atribu's
Shopify app — or, when Atribu has registered a custom-distribution app for that
store, for that one — and back to your `return_url`. The merchant never sees an
Atribu login or a plan page. The store's lifecycle reaches your `atribu`
subscription as `connection.connected`, `connection.revoked` (the merchant
uninstalled the app) and `connection.reconnect_required`, each with
`data.provider: "shopify"`.

```bash title="Connect a Shopify store for one of your tenants"
curl -sX POST https://api.atribu.app/api/v1/connections/shopify/handoff \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "shop": "my-store.myshopify.com", "return_url": "https://app.example.com/integrations" }'
```

### Two links: `start_url` and `url`

**`start_url` goes straight to the provider's consent screen.** No page of ours
renders in between. This is the one a partner app sends its user to: from your
own "Connect" button, to the provider's screen, and (with a `return_url`) back
to your page. Your user never sees a page of ours on that path.

**`url` is a one-screen page** for a human with no app behind them: which
provider, which client, and a Continue button. It shows **your app's name and
logo** (`oauth_apps.name` / `logo_url`) when your app governs the profile,
never ours. It is **session-less**: whoever holds the link completes it,
signed out, on a phone, with no account with us. The person who can grant a
Meta consent is usually the business owner, not the operator running your
agent.

Both links are the same capability, with the same lifetime. The registered
redirect URI with each provider is unchanged, so nothing about the
provider-side app configuration depends on this flow.

### Two ways it completes

Both answer `status: "completed"`, because in both the part only a browser could
do is finished.

| `result` | what happened | what you do next |
|---|---|---|
| `{"provider": "meta_ads", "connection_id": "…"}` | the consent resolved to exactly one account, and it is connected | read it back with `GET /api/v1/connections/{id}` |
| `{"provider": "meta_ads", "pending_selection": {…}}` | the consent exposed several accounts and a human must choose | `GET /api/v1/connections/pending/{provider}`, then `POST …/finalize` |

`pending_selection` carries `candidate_count` and `expires_at`. **That `expires_at`
is a different, shorter clock than the hand-off's own** — the provider token is
parked for 60 minutes, and once it lapses the consent is gone and the connect
must be started again.

### When it fails or is cancelled

`status: "cancelled"` when your user cancelled on the provider's screen;
`status: "failed"` otherwise. `result.reason` says why, and it is the same code
the [bounce](#optional-profile_id-and-return_url) carries:

| `reason` | status | meaning | what your UI does |
|---|---|---|---|
| `user_cancelled` | `cancelled` | your user cancelled or declined on the provider's consent screen | offer to try again |
| `provider_denied` | `failed` | the provider refused the request itself (an app not enabled for this account, a scope it will not grant) | not retryable as-is; the account owner must allow the app at the provider |
| `provider_error` | `failed` | the provider failed (an error page, no authorization code) | retry later |
| `token_exchange_failed` | `failed` | the provider refused to exchange the authorization code | retry; if it repeats, contact support |
| `account_connected_elsewhere` | `failed` | the account is already connected to another profile of this workspace (all six providers; the same account in another workspace is allowed) | disconnect it there first |
| `no_candidates` | `failed` | consent granted, and the account has nothing to connect | **not retryable**: they need access at the provider first |
| `connect_failed` | `failed` | consent granted, but storing the connection failed on our side | retry |
| `internal_error` | `failed` | a transient failure on our side before anything was written. **The hand-off is not settled**: the same `start_url` can be retried | retry the same link |
| `state_expired` | `failed` | the consent screen was open for more than 15 minutes | retry |
| `state_invalid` | `failed` | the flow could not be verified: it was finished in a different browser, or tampered with | retry in one browser |
| `handoff_expired` | `failed` | the hand-off lapsed (45 minutes) | mint a new one |
| `handoff_used` | `failed` | the hand-off had already ended as `failed` or `cancelled`, or another consent for it was still finishing. A second click or tab on a **completed** hand-off normally reports the real outcome (`success` / `pending_selection`) instead; confirm with the poll | poll; mint a new one if it did not complete |
| `provider_not_configured` | `failed` | the provider is not configured on our side | contact support |
| `origin_not_allowed` | `failed` | your app's registration changed after the mint, and the `return_url` is no longer allowed. **Poll only**: there is no registered page to send the user to | fix the registration |

Nothing is written on any of these paths. One exception at the provider:
GoHighLevel installs the app on the location as part of its consent, so an
`account_connected_elsewhere` refusal leaves that install in place on
GoHighLevel's side (nothing is stored on ours). Uninstall it from the location
if it is not wanted.

**One consent per hand-off.** If your user opens `start_url` twice (a
double-click, a forwarded link) and finishes both consents, exactly one of
them is used: the other writes nothing and normally reports the outcome of
the one that won. The bounce is a hint; confirm with the poll. If a consent
stops partway on our side, the link can be used again after 3 minutes. The
state cookie and the signed state both last 15 minutes, so a consent that runs
longer ends `state_expired`.

### The URL is single-use and short-lived

45 minutes, and the moment the hand-off settles: success, failure, or cancel.
Both ends enforce it: an expired link cannot start a consent, and a consent that
finishes after the hand-off lapsed writes **nothing** rather than connecting
late. A spent `start_url` returns your user to your `return_url` with
`handoff_used` or `handoff_expired`; a spent `url` shows which one happened.

Minting twice is two hand-offs, not an error. To retry after a failure or a
cancel, mint a new one.

### Optional: `profile_id` and `return_url`

```json title="Request body (both optional)"
{
  "profile_id": "8f3d…",
  "return_url": "https://app.example.com/onboarding/atribu"
}
```

`profile_id` is an **assertion, not a selector**. Your credential already names
a profile; sending a different one is a `404`. Send it when you want the request
to fail loudly if your key is scoped somewhere you did not expect.

`return_url` sends your user to your own page when the consent ends (success,
failure, or cancel) instead of to the hand-off page. Its **origin must be one
your app can receive a bounce on**: any origin from your app's own
`redirect_uris` works automatically, and `allowed_return_origins` narrows that
set or adds an origin your `redirect_uris` do not cover. **This is checked at
the mint:** an origin outside both sets, a profile no app governs, or (for a
delegated key) a profile governed by a different app than the key's is a
`400 invalid_parameter`, and nothing is created. So is a `return_url` that
carries a username or password (`https://user@host/…`).

The bounce carries:

```text title="Query appended to your return_url"
?connect=<slug>&provider=<provider>&status=<status>&profile_id=<id>&handoff_id=<id>
 [&reason=<reason>]           on failed / cancelled
 [&candidate_count=<n>]       on pending_selection
```

| param | value |
|---|---|
| `status` | `success` (connected), `pending_selection` (a human choice is owed; see below), `failed`, `cancelled` |
| `reason` | one of the [codes above](#when-it-fails-or-is-cancelled) |
| `provider` | the provider you minted with (`meta_ads`, `gohighlevel`, …) |
| `connect` | the older consumer slug (`meta`, `gohighlevel`, …), kept for existing readers |
| `handoff_id` | the hand-off's `id`, so you can match the bounce to what you minted |
| `candidate_count` | a count only, never account ids or names |

Your own query parameters on `return_url` are kept. Treat the bounce as a
signal and read the outcome from `GET /api/v1/handoffs/{id}`: a query string
can be edited by the person holding it.

## Finish a multi-account connect

```http title="Endpoints"
GET  /api/v1/connections/pending/{provider}
POST /api/v1/connections/pending/{provider}/finalize
```

**Scope:** `connections:write` — the same connect-rail scope
[`handoff`](#start-a-connect-and-hand-it-to-a-human) takes, including the
same `attribution_write` swap-window exception.

You only reach these when `result.pending_selection` says a human choice is
owed. `GET` lists the accounts, properties or locations on offer — one flat
shape across providers, with `id` and `name` always present. `POST …/finalize`
commits one of them by `candidate_id`, verbatim.

Retries are safe: repeating the same `candidate_id` answers `200` with
`already_finalized: true`. A **different** one after the choice is made is a
`409` — switching accounts is a new connect, not a retry.

A `candidate_id` that is already connected to **another profile of this
workspace** is `409 account_connected_elsewhere`, and nothing is written: the
parked token stays, so your user can pick a different account.

## Revoke

`DELETE /api/v1/connections/{id}` revokes your app's authorization for the
connection and every API key that authorization minted — including, usually, the
key you are calling with. It does **not** disconnect the underlying data
connection; other consumers and the Atribu console still see it.

## Every route under `/connections`

{/* BEGIN GENERATED ENDPOINT TABLE — generated by scripts/docs/generate-api-docs.ts from openapi.json. Do not hand-edit between these markers; run `npx tsx scripts/docs/generate-api-docs.ts`. */}

| Method | Path | What it does |
|---|---|---|
| `GET` | `/api/v1/connections` | List authorized data connections |
| `GET` | `/api/v1/connections/{id}` | Get a single connection |
| `DELETE` | `/api/v1/connections/{id}` | Revoke this OAuth app's authorization for a connection |
| `POST` | `/api/v1/connections/{id}/disconnect` | Disconnect an integration |
| `GET` | `/api/v1/connections/{id}/pixels` | List the Meta pixels on an ad-account connection |
| `POST` | `/api/v1/connections/{provider}/handoff` | Hand a provider connect to a human and get a URL for them |
| `GET` | `/api/v1/connections/{provider}/stages` | Read a CRM's pipeline stages with the suggested and current outcome mapping |
| `GET` | `/api/v1/connections/pending/{provider}` | List the accounts a pending connect can be finalized against |
| `POST` | `/api/v1/connections/pending/{provider}/finalize` | Finalize a pending connect with the chosen account |
| `POST` | `/api/v1/connections/sync` | Start a sync |
| `GET` | `/api/v1/connections/sync/status` | Read a provider's sync progress |
| `POST` | `/api/v1/integrations/fintoc/link/connect` | Finish a Fintoc bank link and persist the connection |
| `POST` | `/api/v1/integrations/fintoc/link/start` | Start a Fintoc bank link |
| `GET` | `/api/v1/integrations/lhg/outcomes/prefill` | Suggest outcome-event mappings from GoHighLevel's pipeline stages |
| `GET` | `/api/v1/integrations/manychat/connection` | Read the ManyChat connection for this profile |
| `POST` | `/api/v1/integrations/manychat/connection` | Create or update the ManyChat connection for this profile |
| `POST` | `/api/v1/integrations/manychat/connection/rotate-secret` | Regenerate the ManyChat webhook secret |
| `GET` | `/api/v1/integrations/meta/pages` | List the tracked Meta Pages for this profile |
| `POST` | `/api/v1/integrations/meta/pages` | Select which Meta Pages to track |
| `GET` | `/api/v1/integrations/meta/pages/available` | List the Facebook Pages the connected Meta account can see |
| `GET` | `/api/v1/integrations/shopify/manual-connections` | List a profile's manual Shopify webhook connections |
| `POST` | `/api/v1/integrations/shopify/manual-connections` | Create a manual Shopify webhook connection |
| `PATCH` | `/api/v1/integrations/shopify/manual-connections/{connectionId}` | Store the store's webhook signing ID |
| `DELETE` | `/api/v1/integrations/shopify/manual-connections/{connectionId}` | Remove a manual Shopify webhook connection |
| `POST` | `/api/v1/integrations/shopify/web-pixel/activate` | Re-run the Shopify app pixel installation |

Generated from `openapi.json`. The full request and response schema for every operation is in the [OpenAPI document](https://www.atribu.app/api/v1/openapi.json).

{/* END GENERATED ENDPOINT TABLE */}

## Next steps

<Cards>
  <Card title="Step 6 — Connect Meta and Google" href="/docs/getting-started/connect-ads">
    The golden path's own page for this step, both tracks
  </Card>
  <Card title="Hand-offs" href="/docs/api/handoffs">
    The object this route mints, and how to poll it
  </Card>
  <Card title="Readiness" href="/docs/api/readiness">
    Which connections this profile still needs, in order
  </Card>
</Cards>

---

# Tracking & Server-Side Events

Two different jobs live here. **Tracking keys and installers** are how the
browser tracker gets onto a site. **`POST /api/v1/events`** is how a system that
already knows an outcome happened — an ERP, a POS, a back office — tells Atribu
without a browser at all.

## Tracking keys

```http title="Endpoint"
POST /api/v1/tracking/keys
GET  /api/v1/tracking/keys
```

The `POST` is **idempotent by natural key**: a profile that already has an
active key gets that key back rather than a second one. So an agent can call it
unconditionally on every run without accumulating keys.

## Installer payloads

```http title="Endpoints"
GET /api/v1/tracking/snippet
GET /api/v1/tracking/installers/gtm
GET /api/v1/tracking/installers/shopify-pixel
```

`snippet` returns the raw `<script>` (optionally bundled with the Meta Pixel);
the two installers return the payload a Google Tag Manager container or a
Shopify Web Pixel expects. An agent that can edit the site puts the snippet in;
one that cannot hands the payload to the person who can.

The by-hand versions of all three, with screenshots, are in
[Install the tracker](/docs/getting-started/install-tracker).

## Server-side outcome events

```http title="Endpoint"
POST /api/v1/events
```

This is how a dealer's ERP posts a closed sale. Two rules matter more than the
schema:

<Callout type="warn" title="The idempotency key is required whenever the event carries money">
Without one it defaults to a fresh UUID, and a retried sale becomes a **second
conversion**. A payment posted twice is revenue counted twice, and every ROAS
on the profile is wrong afterwards.
</Callout>

`event_type` is **free text**. What gives a name meaning is a
[conversion definition](/docs/api/goals) that lists it — there is no fixed
`sale_closed` type to look up. Post `checkout_completed` and then define what it
means; the definition is what decides whether it counts as cash, and whether it
is attribution-eligible.

### One customer paying for several people — `subject_ref`

A clinic often bills several patients (children, dependants) to one customer:
the parent whose contact carried the ad conversation. By default Atribu counts
**one first payment per customer**, so a second patient's first treatment would
read as a repeat payment — not a new customer, not a `Purchase` at Meta, and
credited only to the ad that acquired the parent.

Send `properties.subject_ref` — **your own opaque id for who the payment is
for** — and each distinct `subject_ref` under one customer gets its own first
payment. A payment without it behaves exactly as before.

**Accepted shapes:** a canonical UUID (`8-4-4-4-12` hex — your internal patient
uuid is ideal), or a short token of up to 32 letters, digits, `_` and `-`.

**Turn it on without inflating new customers.** A subject counts as new only if
every *earlier* payment of that customer carries a different `subject_ref`; an
earlier untagged payment makes it a returning one. So before you start sending
`subject_ref` on new payments, re-send the customer's historical payments with
the **same idempotency key** plus their `subject_ref` — Atribu re-derives the
first-payment flags on that update. A retry that *omits* `subject_ref` removes
it from the stored payment again.

Two consequences of that rule, both deliberate:

- **A household with earlier payments from an integration keeps the
  per-customer rule.** Payments that arrive from MercadoPago, Webpay, a CSV
  import or Shopify cannot carry a `subject_ref`, so they cannot be backfilled;
  a later patient of that household is never counted as a new customer. (At the
  time this shipped, no clinic customer had such a mixed history.)
- **An untagged payment after a tagged one is never a first payment**, even
  within the 7 days that normally group a deposit with its balance — it may be
  the same patient again. Tag both payments to keep them grouped.

```json title="A second patient's first payment"
{
  "event_name": "payment_received",
  "idempotency_key": "invoice_20931",
  "properties": { "value": 45000, "currency": "CLP", "subject_ref": "3f6c1e2a-8b7d-4c1e-9a55-0e2b7d9c4f11" },
  "user_traits": { "phone": "+56911112222" }
}
```

<Callout type="warn" title="subject_ref is never personal data">
Atribu rejects with `400 invalid_parameter` a value that contains an `@`, 7 or
more digits in a row once separators are removed (a RUT/RUN with or without
check digit, even split by a letter, a phone number, a compact date), a date,
a name, or a 32/40/64 character hex digest. Those checks catch mistakes; they
cannot recognise every name (`JuanPerez`) or a hashed or encoded personal
identifier, so `subject_ref` **must** be an opaque id — that is your
obligation under the data-processing agreement. Atribu uses `subject_ref` only
to decide first payments and count new customers — it is never sent to Meta,
Google or TikTok, no API returns it, and it is erased with the customer.
</Callout>

### Retracting an event you sent — `POST /api/v1/events/retractions`

A resend cannot correct a wrong row: Atribu never overwrites a customer it
already resolved with "none". When a backfill or a bug ingested an event that
should not exist (a payment credited to the wrong contact, say), retract it by
the key you sent it with:

```json title="POST /api/v1/events/retractions"
{ "idempotency_key": "invoice_20931", "reason": "Backfill credited the wrong contact" }
```

One transaction deletes the event, the conversions extracted from it and their
attribution, recomputes the first-payment flag of the customer's other payments
(a second payment can become the first), queues the profile's attribution
recompute and writes an audit record naming your key and `reason`. The answer
is counts only. It is idempotent: the same key again answers `200` with
`status: "already_retracted"` and zero counts.

<Callout type="warn" title="Not after an export">
Once a conversion export exists for the event, in any status, the retraction is
refused with `409 event_already_exported` and nothing is deleted: an ad
platform may already have seen it. A WhatsApp or Instagram conversation that
recorded the event as its lead refuses it too (`409 event_linked_to_conversation`).
</Callout>

`POST /api/v1/payments/webpay` is the same idea for Transbank Webpay, which has
no OAuth connection to authorize: the payment is reported directly. See
[Webpay](/docs/integrations/webpay).

<Callout type="info" title="A CRM connection is not required">
[Readiness](/docs/api/readiness)'s `crm_or_outcome_source_connected` step is
satisfied by outcome events arriving on this route. A system that posts its own
outcomes needs no CRM and no browser.
</Callout>

{/* BEGIN GENERATED ENDPOINT TABLE — generated by scripts/docs/generate-api-docs.ts from openapi.json. Do not hand-edit between these markers; run `npx tsx scripts/docs/generate-api-docs.ts`. */}

| Method | Path | What it does |
|---|---|---|
| `POST` | `/api/v1/events` | Ingest a server-side outcome event (e.g. a closed sale) |
| `POST` | `/api/v1/events/retractions` | Retract an event you ingested |
| `GET` | `/api/v1/payments` | List payments, with the merchant actions applied to each |
| `PUT` | `/api/v1/payments/{payment_id}/installment` | Mark (or unmark) a payment as a later installment |
| `PUT` | `/api/v1/payments/{payment_id}/payer` | Assign who paid a payment |
| `POST` | `/api/v1/payments/{payment_id}/refund` | Record a refund made outside the provider |
| `DELETE` | `/api/v1/payments/{payment_id}/refund` | Undo a manually recorded refund |
| `GET` | `/api/v1/payments/linkage` | How each sale reached an ad («Cómo llega la plata») |
| `POST` | `/api/v1/payments/webpay` | Report a Webpay (Transbank) payment |
| `GET` | `/api/v1/tracking/custom-domains` | List the profile's first-party tracking domains |
| `POST` | `/api/v1/tracking/custom-domains` | Register a first-party tracking domain |
| `DELETE` | `/api/v1/tracking/custom-domains/{id}` | Remove a first-party tracking domain |
| `POST` | `/api/v1/tracking/custom-domains/{id}/verify` | Re-check a domain's DNS and store the verdict |
| `GET` | `/api/v1/tracking/events` | The raw enriched-event feed |
| `GET` | `/api/v1/tracking/installers/gtm` | Get the Google Tag Manager installer payload |
| `GET` | `/api/v1/tracking/installers/shopify-pixel` | Get the Shopify Web Pixel installer payload |
| `GET` | `/api/v1/tracking/keys` | List the profile's active tracking keys |
| `POST` | `/api/v1/tracking/keys` | Issue a tracking key (idempotent) |
| `DELETE` | `/api/v1/tracking/keys/{id}` | Revoke a tracking key |
| `GET` | `/api/v1/tracking/ops` | Warehouse diagnostics for the profile |
| `GET` | `/api/v1/tracking/origin` | Where this profile's tracker collects |
| `GET` | `/api/v1/tracking/quality` | Is this profile's tracking healthy? |
| `GET` | `/api/v1/tracking/replay-status` | The profile's most recent attribution replay |
| `GET` | `/api/v1/tracking/settings` | Read the profile's tracker and enrichment settings |
| `PATCH` | `/api/v1/tracking/settings` | Update the profile's tracker and enrichment settings |
| `GET` | `/api/v1/tracking/snippet` | Get the raw tracker snippet (with optional Meta Pixel bundle) |
| `GET` | `/api/v1/tracking/verification` | The latest install-verification attempt |
| `POST` | `/api/v1/tracking/verification` | Start an install verification |

Generated from `openapi.json`. The full request and response schema for every operation is in the [OpenAPI document](https://www.atribu.app/api/v1/openapi.json).

{/* END GENERATED ENDPOINT TABLE */}

## Next steps

<Cards>
  <Card title="Step 5 — Install the tracker" href="/docs/getting-started/install-tracker">
    The golden path's own page for this step
  </Card>
  <Card title="Conversion definitions" href="/docs/api/goals">
    What an event_type has to mean before it counts
  </Card>
  <Card title="How tracking works" href="/docs/tracking/how-tracking-works">
    anonymous_id, session_id, and identity stitching
  </Card>
</Cards>

---

# Conversion Definitions

Atribu stores every outcome event a CRM, a payment provider, the tracker or
your own systems send it. A **conversion definition** is what decides which of
those events *count*, what their value means, and whether they may receive
attribution credit at all.

This is the step where a profile stops producing numbers and starts producing
the *right* numbers.

<Callout type="warn" title="Two catalogs, one selector, and consumers get it wrong">
`GET /api/v1/goals/definitions` returns both. **`conversion_definitions`** are
the actual conversion types, with `revenue_type` and `attribution_eligible` —
build a goal selector from these. **`outcome_definitions`** are CRM
pipeline-stage *labels* ("Descualificado"); they name the buckets outcome
counts are keyed by, carry no revenue or attribution semantics, and are not
goals.
</Callout>

## The three fields that decide everything

| field | what it decides |
|---|---|
| `source_event_names` | Which raw `outcome_events.event_type` values this definition claims. Unique across the profile — an overlap answers `409`, because one event producing two conversions double-counts it in every report. |
| `revenue_type` | `cash` \| `pipeline` \| `gross`. **Only `cash` counts toward ROAS.** A profile whose definitions are all `pipeline` records conversions that can never produce one. |
| `attribution_eligible` | Whether this conversion may be credited to a touch at all. |

`conversion_key` is taken **verbatim**, never derived from `display_name`:
renaming a goal must not silently re-key every filter and saved report pointing
at it.

## Suggestions first, then preview, then create

```http title="What is arriving that nothing claims"
GET /api/v1/goals/definitions/suggestions
```

The gap between what Atribu **stores** and what it **counts**: every
`event_type` seen in the window that no definition names. Each row carries how
often it fires, what it is worth, how many distinct people it reaches — and
pre-filled `suggested_revenue_type`, `suggested_display_name`,
`suggested_attribution_eligible` and `suggested_meta_event_name`. Those are
form fields, not classifications: an agent should show them to a human, or at
least say it accepted them.

It reads `outcome_events`, a **durable** table, so the window is not limited to
the 30-day ephemeral buffer.

```http title="What a candidate would capture"
POST /api/v1/goals/definitions/preview
POST /api/v1/goals/definitions/diff
```

`preview` answers "how many events, worth how much" for a candidate. `diff`
compares an existing definition against a proposed edit. Both are free of
consequence — call them before writing.

<Callout type="warn" title="`revenue_sum` in a preview is not revenue">
It is the raw event-value total for the matching events. Revenue is what the
attribution pipeline produces after the definition exists.
</Callout>

```http title="Create it"
POST /api/v1/goals/definitions
```

**Scope:** `goals:write`. Creating or changing a definition stamps an audit row
and queues **one full-profile replay** — a definition change re-derives the
profile's conversions from its stored outcome events, so past periods change
too. That is the intended behaviour, and it is why `diff` exists.

## The legacy route

`POST /api/v1/goals` still answers, and is **deprecated**. It carries a
`Deprecation` header, a `Sunset` date once one is set, and a
`Link: …; rel="successor-version"` pointing at
`POST /api/v1/goals/definitions`. Nothing is removed with less than 90 days'
notice — see the [deprecation policy](/docs/api/overview) and the
[API changelog](https://github.com/AtribuCore/atribu/blob/main/docs/api-changelog.md).

{/* BEGIN GENERATED ENDPOINT TABLE — generated by scripts/docs/generate-api-docs.ts from openapi.json. Do not hand-edit between these markers; run `npx tsx scripts/docs/generate-api-docs.ts`. */}

| Method | Path | What it does |
|---|---|---|
| `GET` | `/api/v1/goals` | List conversion goals |
| `POST` | `/api/v1/goals` | Create a conversion goal |
| `DELETE` | `/api/v1/goals` | Delete a conversion goal |
| `GET` | `/api/v1/goals/definitions` | List the profile's outcome and conversion definitions |
| `POST` | `/api/v1/goals/definitions` | Create one conversion definition |
| `PATCH` | `/api/v1/goals/definitions/{id}` | Update one conversion definition |
| `DELETE` | `/api/v1/goals/definitions/{id}` | Delete one conversion definition |
| `POST` | `/api/v1/goals/definitions/diff` | Compare an existing conversion definition against a proposed edit |
| `POST` | `/api/v1/goals/definitions/preview` | Preview what a candidate conversion definition would capture |
| `GET` | `/api/v1/goals/definitions/suggestions` | List outcome events this profile receives but no conversion definition claims |
| `GET` | `/api/v1/goals/outcome-counts` | Count the profile's outcome events in a window, by type |
| `PATCH` | `/api/v1/outcome-definitions/{id}` | Update one outcome-definition's event key |

Generated from `openapi.json`. The full request and response schema for every operation is in the [OpenAPI document](https://www.atribu.app/api/v1/openapi.json).

{/* END GENERATED ENDPOINT TABLE */}

## Next steps

<Cards>
  <Card title="Step 9 — Define conversions" href="/docs/getting-started/define-conversions">
    The golden path's own page for this step, both tracks
  </Card>
  <Card title="Revenue types" href="/docs/concepts/revenue-types">
    Why only cash counts for ROAS
  </Card>
  <Card title="Conversion goals in the app" href="/docs/settings/goals">
    The same objects, edited by hand
  </Card>
</Cards>

---

# Channel Rules

A **channel rule** maps raw traffic — referrer, UTM source and medium, click
ids — onto one of the workspace's channel names ("Paid Social", "Organic
Search", "Direct"). Every breakdown, every filter and every report that says
*channel* is reading the result.

Rules are **ordered**: the first match wins, so priority is part of the rule,
not a tie-break.

## Look before you write

The three read/preview routes exist because a rule change re-labels history,
not just new traffic:

```http title="Endpoints"
GET  /api/v1/channel-rules/suggestions
POST /api/v1/channel-rules/match-count
POST /api/v1/channel-rules/diff
```

- **`suggestions`** proposes rules for traffic that is currently unclassified —
  the honest starting point when a large share of sessions lands in "Direct" or
  "Other".
- **`match-count`** answers "how much traffic would *this one candidate* match".
- **`diff`** projects what a **whole candidate rule set** would re-label, which
  is the only one that catches a new rule stealing traffic from an existing one.

<Callout type="warn" title="Suggestions read the ephemeral buffer">
Channel-rule suggestions are computed from session-grain data in the Ephemeral
Buffer, whose retention is 30 days. A window longer than that is clamped, and
the response says so. Conversion-definition suggestions are different — they
read a durable table. See
[the range invariant](/docs/concepts/attribution-models).
</Callout>

## Writing

```http title="Endpoints"
POST   /api/v1/channel-rules
PATCH  /api/v1/channel-rules/{id}
DELETE /api/v1/channel-rules/{id}
POST   /api/v1/channel-rules/reorder
POST   /api/v1/channel-taxonomy
```

`reorder` swaps two rules' priorities **atomically** — reordering by two
`PATCH` calls leaves a window in which both rules hold the same priority, and
the classifier is order-dependent.

`POST /api/v1/channel-taxonomy` adds a channel *name* to the workspace. A rule
can only point at a channel that exists.

{/* BEGIN GENERATED ENDPOINT TABLE — generated by scripts/docs/generate-api-docs.ts from openapi.json. Do not hand-edit between these markers; run `npx tsx scripts/docs/generate-api-docs.ts`. */}

| Method | Path | What it does |
|---|---|---|
| `GET` | `/api/v1/channel-rules` | List the profile's channel-classification rules and the channel taxonomy |
| `POST` | `/api/v1/channel-rules` | Create one channel-classification rule |
| `PATCH` | `/api/v1/channel-rules/{id}` | Update one channel-classification rule |
| `DELETE` | `/api/v1/channel-rules/{id}` | Delete one channel-classification rule |
| `POST` | `/api/v1/channel-rules/diff` | Project what a whole candidate rule set would re-label |
| `POST` | `/api/v1/channel-rules/match-count` | Count how much traffic one candidate rule would match |
| `POST` | `/api/v1/channel-rules/reorder` | Swap two rules' priorities atomically |
| `GET` | `/api/v1/channel-rules/suggestions` | Suggest channel rules for traffic that is currently unclassified |
| `POST` | `/api/v1/channel-taxonomy` | Add a channel to this workspace's taxonomy |

Generated from `openapi.json`. The full request and response schema for every operation is in the [OpenAPI document](https://www.atribu.app/api/v1/openapi.json).

{/* END GENERATED ENDPOINT TABLE */}

## Next steps

<Cards>
  <Card title="Channels" href="/docs/concepts/channels">
    What the default taxonomy is and how traffic reaches it
  </Card>
  <Card title="Breakdowns" href="/docs/api/breakdowns">
    Reading conversions and revenue by channel
  </Card>
  <Card title="UTM parameters" href="/docs/tracking/utm-parameters">
    What the classifier is reading in the first place
  </Card>
</Cards>

---

# Profile & Data Quality

Everything under `/api/v1/profile` and `/api/v1/quality` is a property of the
**profile** rather than of a date range: how it is configured, how fresh its
attribution is, and how much of its traffic and revenue can be explained at all.

## The four reads worth knowing by name

| route | question it answers |
|---|---|
| [`GET /profile/readiness`](/docs/api/readiness) | What is still missing, in order, and the one next thing to do about it. |
| `GET /profile/freshness` | When this profile's attribution was last recomputed. A dashboard that looks wrong is often just not recomputed yet. |
| `GET /quality/attribution` | How many conversions carry a touch at all — attribution *coverage*, not accuracy. |
| `GET /trust` | The trust signals behind the dashboard's own honesty banners. |

`GET /profile` is the profile's rendering context: name, currency, PII mode,
entitlements, and the legacy `onboarding_completed` boolean.

<Callout type="warn" title="`onboarding_completed` is not readiness">
It means one thing: a person pressed **Finish** in the setup wizard. Every
wizard step is skippable, so it is not evidence that any step is done. Use
[readiness](/docs/api/readiness) for that, and this only to tell "has been
through onboarding and is still missing things" from "nobody has started".
</Callout>

## Attribution windows

`GET` / `PATCH /api/v1/profile/attribution-settings` read and write the click,
view-through, first-touch and cash windows. They are documented in full, with
both tracks, on [Attribution windows](/docs/settings/attribution-windows) —
including the part that matters most: **every successful `PATCH` queues a
full-profile replay**, because a window change redefines which touches attach
to which conversion retroactively.

## UTM health

`GET /api/v1/quality/utm`, `/quality/utm/anomalies` and
`POST /quality/utm/refresh` are the instrumentation audit: links that lost their
parameters, campaigns whose UTMs disagree with the ad they came from, sudden
drops in tagged traffic. `POST /quality/utm/refresh` runs the audit now rather
than waiting for the scheduled pass.

{/* BEGIN GENERATED ENDPOINT TABLE — generated by scripts/docs/generate-api-docs.ts from openapi.json. Do not hand-edit between these markers; run `npx tsx scripts/docs/generate-api-docs.ts`. */}

| Method | Path | What it does |
|---|---|---|
| `POST` | `/api/v1/dashboard/chat` | Retired: Ask Atribu |
| `GET` | `/api/v1/profile` | Get the authenticated profile's rendering context |
| `PATCH` | `/api/v1/profile` | Update the authenticated profile's settings |
| `GET` | `/api/v1/profile/attribution-settings` | Get the profile's attribution windows |
| `PATCH` | `/api/v1/profile/attribution-settings` | Update the profile's attribution windows |
| `GET` | `/api/v1/profile/attribution-settings/recommendation` | Recommend attribution windows from the profile's own sales cycle |
| `POST` | `/api/v1/profile/audit-events` | Stamp one allowlisted UI-interaction audit event |
| `GET` | `/api/v1/profile/bio-page` | The profile's hosted bio-page handle and config |
| `PUT` | `/api/v1/profile/bio-page` | Set the bio-page handle and config |
| `GET` | `/api/v1/profile/bio-page/slug-availability` | Whether a bio-page handle is free |
| `GET` | `/api/v1/profile/branding` | The profile's report/email branding |
| `PUT` | `/api/v1/profile/branding` | Update the profile's branding (non-logo fields) |
| `POST` | `/api/v1/profile/branding/logo` | Upload the profile's logo |
| `DELETE` | `/api/v1/profile/branding/logo` | Remove the profile's logo |
| `POST` | `/api/v1/profile/creative-analysis/backfill` | Mark a profile's back catalogue for creative analysis |
| `GET` | `/api/v1/profile/dark-messaging-spend` | Estimate revenue at risk from unattributed Instagram DM conversations |
| `GET` | `/api/v1/profile/freshness` | Get when this profile's attribution data was last recomputed |
| `PUT` | `/api/v1/profile/outcome-definitions` | Replace one family of outcome definitions |
| `GET` | `/api/v1/profile/pacing` | The profile's monthly budget and outcome goal |
| `PUT` | `/api/v1/profile/pacing` | Set the profile's monthly budget and outcome goal |
| `POST` | `/api/v1/profile/payments/import/commit` | Commit a previously-parsed payments import |
| `POST` | `/api/v1/profile/payments/import/parse` | Parse a payments statement or sales sheet (preview) |
| `GET` | `/api/v1/profile/payments/import/template` | Get an importer's CSV template |
| `GET` | `/api/v1/profile/payments/imports` | List past payments imports |
| `POST` | `/api/v1/profile/payments/imports/{importId}/undo` | Undo a completed Webpay import |
| `GET` | `/api/v1/profile/readiness` | Get the profile's setup checklist and the one next thing to do |
| `POST` | `/api/v1/profile/recompute` | Recompute attribution |
| `POST` | `/api/v1/profile/recompute-fx` | Re-normalise the profile's conversions after a currency change |
| `GET` | `/api/v1/profile/recompute-status` | Is a recompute still running? |
| `GET` | `/api/v1/profile/revenue-scope` | List the profile's Stripe products and whether ads sell them |
| `PATCH` | `/api/v1/profile/revenue-scope` | Answer whether ads sell a product |
| `GET` | `/api/v1/profile/share-destination` | The profile's default destination for organic share links |
| `PUT` | `/api/v1/profile/share-destination` | Set or clear the default share destination |
| `GET` | `/api/v1/quality` | Get attribution data quality |
| `GET` | `/api/v1/quality/ad-ids` | Get paid-Touch ad-id coverage |
| `GET` | `/api/v1/quality/ad-ids/url-tags` | Get the URL-parameter apply state |
| `POST` | `/api/v1/quality/ad-ids/url-tags/apply` | Apply Atribu's URL parameters to Meta ads |
| `POST` | `/api/v1/quality/ad-ids/url-tags/undo` | Undo a URL-parameter change |
| `GET` | `/api/v1/quality/attribution` | Get attribution coverage — how many conversions can be attributed at all |
| `GET` | `/api/v1/quality/instrumentation-health` | Get instrumentation health |
| `GET` | `/api/v1/quality/itp` | Get Safari ITP exposure |
| `GET` | `/api/v1/quality/utm` | Get UTM / tracking-health quality signals |
| `GET` | `/api/v1/quality/utm/anomalies` | Get the UTM anomaly breakdown |
| `POST` | `/api/v1/quality/utm/refresh` | Run the UTM health audit now |
| `GET` | `/api/v1/trust` | Get attribution trust signals |

Generated from `openapi.json`. The full request and response schema for every operation is in the [OpenAPI document](https://www.atribu.app/api/v1/openapi.json).

{/* END GENERATED ENDPOINT TABLE */}

## Next steps

<Cards>
  <Card title="Readiness" href="/docs/api/readiness">
    The ordered checklist and its four statuses
  </Card>
  <Card title="Attribution windows" href="/docs/settings/attribution-windows">
    Click, view-through, first-touch and cash windows
  </Card>
  <Card title="Data quality" href="/docs/features/data-quality">
    The same signals, rendered in the console
  </Card>
</Cards>

---

# Readiness

```http title="Endpoint"
GET /api/v1/profile/readiness
```

**Scope:** `analytics:read`

Everything a profile needs before ads, outcomes and attribution work end to end,
as an ordered list of steps — and for every step that is not done, the one next
action.

This is the read to make when a dashboard is empty and you need to say *why*.
Before it existed, answering that meant calling `/connections`,
`/goals/definitions`, `/goals/outcome-counts`, `/exports/destinations`,
`/conversion-sync/legal`, `/quality/attribution` and `/profile/freshness` and
reasoning about the result yourself.

## Derived, never stored

There is no checklist table. Every step is computed at read time from the state
the rest of the product already keeps, so a connection that broke an hour ago
reads `degraded` on the next call, and a conversion definition you created a
second ago is reflected immediately. Nothing here is cached.

<Callout type="warn">
`wizard_finished` is **not** the answer to this question. It is the legacy
`onboarding_completed` boolean on `GET /api/v1/profile`, and it means exactly one
thing: a person pressed **Finish** in Atribu's own setup wizard. Every wizard step
is skippable, so it is not evidence that any step below is done — nothing in
`summary` is derived from it. Use it only to tell "has been through onboarding and
is still missing things" from "nobody has started".
</Callout>

## Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/profile/readiness"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
import { Atribu } from "@atribu/node";

const atribu = new Atribu({ apiKey: "atb_live_YOUR_KEY" });
const { data } = await atribu.profile.readiness();

console.log(data.summary.next_step); // "capi_destination_enabled"
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

res = requests.get(
    "https://api.atribu.app/api/v1/profile/readiness",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
)
data = res.json()["data"]
```
</Tab>
</Tabs>

There are no parameters. This is a property of the profile's configuration, not
of a date range — the only clock involved is the tracker step's seven-day
look-back, which is part of that step's definition.

## Response

```json title="200 OK"
{
  "data": {
    "steps": [
      {
        "key": "attribution_enabled",
        "status": "done",
        "why": "Atribu's attribution pipeline is enabled for this profile.",
        "next": null,
        "docs_url": "https://www.atribu.app/docs/getting-started/overview"
      },
      {
        "key": "ad_platform_connected",
        "status": "degraded",
        "why": "meta_ads is connected but its status is \"reconnect_required\" — it is not delivering spend or delivery data. Re-authorize it; the account itself is already linked, so this is a reconnect rather than a first connect.",
        "next": { "handoff_kind": "connect" },
        "docs_url": "https://www.atribu.app/docs/getting-started/connect-ads"
      },
      {
        "key": "conversion_definitions_valid",
        "status": "missing",
        "why": "This profile has no conversion definition that is both revenue_type 'cash' and attribution_eligible, so nothing it records can ever produce a ROAS. Create one with POST /api/v1/goals/definitions; GET /api/v1/goals/definitions/suggestions proposes definitions from the outcome events already arriving.",
        "next": { "method": "POST", "path": "/api/v1/goals/definitions" },
        "docs_url": "https://www.atribu.app/docs/settings/goals"
      }
    ],
    "summary": { "done": 9, "total": 12, "next_step": "ad_platform_connected" },
    "wizard_finished": true
  },
  "meta": { "profile_id": "your-profile-id" }
}
```

`steps` is **always all twelve steps**, in golden-path order. A checklist that
omitted the steps it could not evaluate would have a length that varies by
profile, and `done`/`total` would stop meaning anything.

## The four statuses

| status | meaning |
| --- | --- |
| `done` | Satisfied. `next` is `null`. |
| `missing` | Never configured. The ordinary next thing to do. |
| `degraded` | Configured, and **not working** — a reconnect-required ad account is the canonical case. The fix is to *re*-connect, so a client that drew this as `missing` would tell someone to connect what they already connected. |
| `blocked` | Cannot be satisfied by you at all, whatever you do. Today only `attribution_enabled` reaches it. |

Only `done` counts toward `summary.done`. **`degraded` is not partial credit**: a
destination that is configured and failing exports nothing, and a checklist that
scored it as progress would read 12/12 while the profile sends Meta nothing.

## `next` — what to do about it

Every step that is not `done` carries a `next`, in one of two shapes.

**`{"method", "path"}`** is a call you can make right now with the credential you
already hold. The path is always one this API serves *today*, never a route that
is being built. Where a step's own write does not exist on v1 yet, the nearest
existing route is named and `why` says where the write actually happens.

**`{"handoff_kind"}`** is a class of action only a **person** can take:

| kind | why an agent cannot do it |
| --- | --- |
| `connect` | An OAuth consent. It needs a browser and someone who can grant it. |
| `sign_dpa` | A signature. One forged on a human's behalf is not a signature. |
| `checkout` | A payment. |
| `contact_support` | Atribu itself has to change something — see `attribution_enabled` below. |

A `handoff_kind` tells you *what* kind of hand-off is owed, not where: minting the
actual URL is a separate capability.

`summary.next_step` is the **first** non-`done` step in golden-path order, or
`null` when everything is done. First, not "most important" — the order *is* the
priority.

## The steps

### `attribution_enabled`

Whether the `atribu_attribution` entitlement is on for this profile. When it is
not, Atribu's whole pipeline — recompute, conversion extraction, automatic
events — is a deliberate no-op, so **every other step can be finished and still
produce no attributed conversions**.

<Callout type="warn">
Profiles created through a partner application deliberately do not hold this
entitlement. Such a profile answers `blocked` with `contact_support`, **never an
empty checklist** — the rest of the steps are still evaluated and still report
what they find, because "well configured" and "will never attribute anything" are
both facts you need.
</Callout>

### `tracker_installed`

At least one tracking event reached Atribu in the last **7 days**. `missing`
points at `GET /api/v1/tracking/snippet`; the GTM and Shopify-pixel variants are
at `/api/v1/tracking/installers/gtm` and
`/api/v1/tracking/installers/shopify-pixel`.

### `ad_platform_connected`

A `meta_ads` or `google_ads` connection exists and is healthy (`connected` or
`syncing`). One healthy provider is enough — a working Meta connection beside a
broken Google one is `done`.

### `crm_or_outcome_source_connected`

Something is telling Atribu when a lead, appointment or sale happens. Satisfied
by a healthy `gohighlevel` or `shopify` connection, **or** by outcome events
arriving through `POST /api/v1/events` — a system that posts its own outcomes
needs no CRM connection and no browser.

A connection that exists and is broken outranks the API-events fallback in the
`why`: an ERP that posted an event last month does not make a broken GoHighLevel
link fine.

### `payments_connected`

A healthy `stripe`, `mercadopago` or `shopify` connection, or outcome events from
`POST /api/v1/payments/webpay`. Without one, no conversion can carry cash and
every ROAS on the profile is unmeasurable.

### `conversion_definitions_valid`

At least one `conversion_definitions` row that is both `revenue_type: "cash"`
**and** `attribution_eligible`. **Only cash counts toward ROAS** — a profile whose
definitions are all `pipeline` records conversions that can never produce one.
`GET /api/v1/goals/definitions/suggestions` proposes definitions from the outcome
events already arriving.

### `attribution_windows_set`

A `profile_attribution_settings` row exists. `why` says whether the windows are
**explicit** or Atribu's defaults (click 30 d, view-through 24 h, first-touch
90 d). Defaults are a legitimate answer, so both are `done`.

`next` names `PATCH /api/v1/profile/attribution-settings` (#1060), which writes
`click_window_days`, `view_window_hours`, `first_touch_window_days` and
`cash_window_days` directly and creates the row if it does not exist yet. The
same fields are editable in the Atribu console under Attribution.

### `first_conversion_attributed`

At least one conversion has been credited to a touch. This is an **outcome** of
the steps above rather than a setting: finish them, then
`POST /api/v1/attribution/recompute` to re-project without waiting for the
scheduled run. `GET /api/v1/quality/attribution` shows how many conversions carry
a touch at all.

### `capi_destination_enabled` · `capi_rules_enabled`

An enabled export destination, and at least one enabled signal rule. **A
destination with no rules sends nothing**, which is why they are two steps.
Destination and rule writes are not on this API yet; `next` names the reads that
show what is configured (`GET /api/v1/exports/destinations`,
`GET /api/v1/ads/signals`) and `why` says the writes happen in the Atribu console
under Conversion Sync.

### `legal_dpa`

The Data Processing Agreement has been accepted.
`GET /api/v1/conversion-sync/legal` reads the full compliance record.

### `plan`

`done` unless the workspace's plan limit is already spent. When active profiles
have reached `max_active_profiles` the step is `degraded`, not `blocked`: **the
profile in hand keeps working**, and what is refused is adding another one. A
workspace with no subscription row at all reads `missing`. An `admin` or
`beta_tester` override bypasses the limit and says so.

### `whatsapp_dataset_connected`

Only meaningful for a profile with a WhatsApp Business account. **It is `done`
for every profile without one** — there is no click-to-WhatsApp traffic to
attribute, so there is nothing to wire.

With a WABA and no enabled WhatsApp dataset, it is `missing`: click-to-WhatsApp
conversions still ship, to the website pixel, where Meta cannot credit them to
the click-to-WhatsApp ad that produced them. `next` is a handoff rather than a
call — creating the dataset needs the customer's Meta token, and when Meta
withholds `business_management` a person has to paste the dataset id out of
Events Manager. `GET /api/v1/exports/destinations` reads back what is wired,
including each destination's `data_source_kind`.

## What this response does not contain

No account ids, no pixel ids, no amounts, no PII. It says which providers are
connected, how many rules exist and which plan tier the workspace is on — which
is why it sits at `analytics:read` rather than a higher scope. A credential that
can read the dashboard can be told why the dashboard is empty.

## Next steps

<Cards>
  <Card title="Install the tracker" href="/docs/getting-started/install-tracker">
    The first step for most profiles
  </Card>
  <Card title="Conversion goals" href="/docs/settings/goals">
    Cash definitions, and why only cash counts
  </Card>
  <Card title="Conversion Sync" href="/docs/features/conversion-sync">
    Destinations, rules and the DPA
  </Card>
</Cards>

---

# Conversion Sync

Sending a conversion back to Meta is four calls in a fixed order:

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

<Callout type="warn" title="Steps 1 and 2 are both required, and both are silent when missing">
  `POST /api/v1/exports` succeeds against a profile with no destination and no
  enabled rule, and ships nothing. The export pipeline iterates the rules and
  builds one candidate per (rule x destination); with zero of either it builds
  zero candidates, for every destination, unconditionally. **There is no default
  mapping** — a profile with conversion definitions but no signal rule exports
  nothing, forever, with no error anywhere.
</Callout>

**Scopes:** `exports:read` for the reads and the preview, `exports:write` for
every mutation and for the test send. `exports:write` is not granted by any
OAuth connect scope — a workspace admin opts a key into it.

`POST /api/v1/exports` itself stays on `attribution:write`. Configuring a
destination and transmitting a customer's conversion to it are different acts,
and the second one is the disclosure to a third party.

---

## Step 1 — the destination

```http title="Endpoint"
POST /api/v1/exports/destinations
```

**Scope:** `exports:write`

```json title="Request"
{
  "destination": "meta_capi",
  "connection_id": "0d0e6c8a-…",
  "meta_pixel_id": "1234567890",
  "enabled": true,
  "destination_config": {
    "signal_quality": { "preset": "balanced" }
  }
}
```

| Field | Required | Notes |
|---|---|---|
| `destination` | yes | `meta_capi` or `google_ads`. **Immutable** — a `PATCH` that changes it is a `422`. |
| `connection_id` | yes | A `data_connections` id on this profile. Its provider must match the kind: `meta_capi` needs `meta_ads`. |
| `meta_pixel_id` | for `meta_capi` | The dataset / pixel id from Events Manager. |
| `google_conversion_action` | for `google_ads` | `customers/{id}/conversionActions/{id}`. |
| `enabled` | no | Defaults `true`. |
| `destination_config` | no | Free-form settings. Deep-merged on `PATCH`. |

List what already exists with `GET /api/v1/exports/destinations`; each row
carries a `routing` verdict (`active` / `disabled` /
`suppressed_platform_safe`) that says whether it will actually fire on the next
run, and a `connection.status` that is the usual reason a healthy-looking
destination stopped delivering.

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

<Callout type="info" title="PATCH deep-merges destination_config">
  A partial update that sends only `signal_quality` keeps the keys it did not
  mention — including the `data_source` block that records which Meta dataset a
  messaging destination was wired to. Replacing the object would strand the
  destination on a dataset nothing points at.
</Callout>

`DELETE /api/v1/exports/destinations/{id}` is **soft**: the row is set
`enabled: false` and kept, because the deliveries ledger references it and is
the record of every event ever shipped through that dataset. Re-enable with
`PATCH … {"enabled": true}`.

---

## Step 2 — the rule

```http title="Endpoint"
POST /api/v1/exports/rules
```

**Scope:** `exports:write`

```json title="Request"
{
  "name": "Payment → Purchase",
  "source_kind": "conversion_definition",
  "source_ref_id": "6f1c…",
  "destinations": { "meta_capi": { "enabled": true } },
  "platform_event_name_overrides": { "meta_capi": "Purchase" },
  "meta_custom_event_type": "PURCHASE",
  "value_mode": "event_value",
  "priority": 100
}
```

`source_ref_id` is a **`conversion_definitions`** id — the row that says which
`outcome_events` count as a conversion and whether they carry cash. Get the
list from `GET /api/v1/goals/definitions`. (`outcome_definitions` are CRM
pipeline-stage labels and are a different thing entirely.)

| `value_mode` | Sends |
|---|---|
| `event_value` | the conversion's own amount and currency |
| `fixed_value` | `source_config.fixed_value` + `source_config.fixed_currency` |
| `none` | no value at all |
| `cash` | the amount actually **collected** — the value of a `cash` conversion; no value for anything that collected nothing (a gross order, a pipeline deal), which a `Purchase` then holds as `meta_value_required_missing` rather than claim money that was not received |
| `net` | `cash` minus every refund and dispute Atribu holds for that sale when the export is built (a won dispute adds back), never below 0. A refund that arrives after the send is not re-sent — a platform cannot amend a delivered event |

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

<Callout type="error" title="value_mode: none on a Purchase is rejected">
  Meta documents `value` and `currency` as **required** for purchase events, so
  such a rule can only ever emit a payload Meta refuses. The route answers `422`
  naming the event. This is not theoretical: one profile shipped that config for
  a month — 152 dead-lettered Purchases, 306 of them permanently unrecoverable,
  because Meta's replay window is 7 days.
</Callout>

A `pipeline_stage_transition` rule maps a CRM stage change instead of a
conversion: put `to_stage` (or `from_stage` with `trigger_mode:
"stage_exited"`) in `source_config`, and leave `value_mode` at `none` — the
stage sender has nowhere to put an amount.

`meta_custom_event_type` is Meta's optimization category, and **its spellings
are Meta's, not the event names'**: `INITIATED_CHECKOUT` (not
`INITIATE_CHECKOUT`) and `CONTENT_VIEW` (not `VIEW_CONTENT`). An unknown value
is a `422` rather than a Postgres error at write time.

### New-customer purchase event

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

### Which platform receives each sale

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

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

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

### Subscription renewals

`recurring_payment_mode` decides what the rule does with a **renewal** — a
`cash` `payment_received` conversion whose `is_first_payment` is `false`.

| `recurring_payment_mode` | What ships |
|---|---|
| `skip` (default) | nothing; the delivery is recorded as `skipped` with `skip_reason: "recurring_payment"` |
| `custom_event` | a Meta custom event named `SubscriptionRenewal`, same value and currency. Google Ads has no custom-event equivalent, so a Google destination skips with `recurring_payment_google_no_custom_event` |
| `purchase` | the rule's normal event, at full value |

**First payments are never affected**, under any mode. Neither are refunds
(`payment_refunded` is also `cash` with `is_first_payment: false`) nor any
non-cash conversion.

<Callout type="warn" title="Every existing rule was backfilled to skip">
  Meta documents `Purchase` as a checkout completion and `Subscribe` as the
  START of a paid subscription; it documents nothing for a renewal. Sending
  renewals as `Purchase` teaches the optimizer that the acquiring ad keeps
  producing new purchases every month, and Meta credits them to whatever ad the
  customer touched since. Meta cannot unsend, so the migration that added this
  field set every pre-existing rule to `skip`. Set `purchase` explicitly if your
  business genuinely wants renewals counted at the platform.
</Callout>

`is_first_payment` is read from the conversion at evaluation time and is never
recomputed by the exporter — it can legitimately flip later (a backfilled
earlier payment, or the manual override API). A delivery already skipped as a
renewal is not re-sent automatically; replay it like any other skipped row.

### Preview before you commit

```http title="Endpoint"
POST /api/v1/exports/rules/preview
```

**Scope:** `exports:read`. Writes nothing.

Send the same body a create would take; get back how many source records the
rule would have matched over the last 7 days and which Meta event it would
emit.

```json title="Response"
{
  "data": {
    "days": 7,
    "candidate_count": 41,
    "matched_on": ["payment_received"],
    "platform_event_name": "Purchase"
  }
}
```

`candidate_count` is an **upper bound**, never a forecast of deliveries: a
matched record still has to clear identity, the privacy guard, the platform's
staleness window and destination routing. A `0` with a non-empty `matched_on`
means the rule is well-formed and this profile simply had no such events.

### Reading rules back

`GET /api/v1/exports/rules` returns the live (non-archived) rules with
`delivery_stats_7d` on each. **An enabled rule sitting at `sent: 0` is the most
common broken state on this surface** — usually a disabled destination, a
definition that matches nothing, or a Meta event with no working Custom
Conversion behind it.

`DELETE /api/v1/exports/rules/{id}` answers `409 invalid_state` once the rule
has export history, because the ledger records every event sent under it.
Archive instead — `PATCH` with `archived_at` — which has the same effect on the
pipeline.

---

## Step 3 — the test event

```http title="Endpoint"
POST /api/v1/exports/test
```

**Scope:** `exports:write`

```json title="Request"
{ "destination_id": "…", "test_event_code": "TEST12345", "rule_id": "…" }
```

Get `test_event_code` from Events Manager → Test Events. Without it the event
is a real (if synthetic) event on the dataset rather than a test one.

Everything is optional: omit `destination_id` and the oldest enabled Meta
destination is used; pass `rule_id` to borrow that rule's Meta event name and
privacy override, so the test exercises the rule you are actually verifying.
`channel: "business_messaging"` sends a click-to-WhatsApp shaped event instead
(sample `ctwa_clid`, `messaging_channel: whatsapp`).

```json title="Response"
{
  "data": {
    "meta_pixel_id": "1234567890",
    "event_id": "atribu_signals_test_1757030400000",
    "channel": "website",
    "action_source": "website",
    "filters_applied": [],
    "privacy_mode": "standard",
    "quality_warning": "This test event was sent with only IP and user agent …",
    "meta_response": { "events_received": 1, "fbtrace_id": "Az9…" }
  }
}
```

Search `event_id` in Events Manager → Test Events to confirm arrival.

- The payload carries the same `atribu_src` marker a production event carries,
  so the test actually fires the Custom Conversion your rule created. A test
  that skipped it would fire the pixel and *not* the conversion — the most
  misleading possible result while verifying a setup.
- `quality_warning` is non-null when this profile's match-quality policy would
  block a real event carrying only what a test carries (an IP and a user agent).
  **The test still sends** — a test that could not run under a strict policy
  would prove nothing.
- `409 invalid_state` means the profile's privacy or HIPAA legal gate refused
  the payload; `502` means Meta rejected it, with Meta's own message quoted.
- Capped at **5 sends per profile per minute**.

---

## Step 4 — export

```http title="Endpoint"
POST /api/v1/exports
```

**Scope:** `attribution:write`

Covered in full under [Conversions & Revenue](/docs/api/conversions). In short: it
queues a run and answers `202` with a batch id; poll
`GET /api/v1/exports/{id}` for what happened, and read
`GET /api/v1/exports/ledger` for the per-delivery detail. A non-empty ledger is
the proof the four steps worked.

### Where each event came from (`action_source`)

Each ledger entry carries `action_source`: what the Meta payload actually said.
An outcome posted to `POST /api/v1/events` with `action_source` is sent with that
value, and any non-`website` value ships without `event_source_url` (a
`platform_safe` or `hipaa` profile sends no URL on any event, `website` included,
and floors `privacy_config.url_sanitization` to `omit`); one posted
without it is inferred as before (see
[Which `action_source` to send](/docs/api/partners#which-action_source-to-send)).
A click-to-WhatsApp outcome goes to the WhatsApp Business Account's own dataset
while the merchant's WhatsApp connection is live, including in `platform_safe`.

---

## `replay_queued`

Every mutation on this page returns `replay_queued` and `replay_error`
alongside the row it wrote.

A configuration change only reaches the pipeline when a run picks it up, and
the scheduled run is hourly. These writes queue one standard export pass so the
change takes effect now, and report whether that succeeded — `replay_queued:
false` with an `replay_error` means the write landed and the pass did not, so
the change waits for the next hourly sweep instead of being lost.

It says a pass was **asked for**. It never means anything shipped.

---

# Hand-offs

Most of Atribu is completable by a program. A few steps are not, by design:
granting an OAuth consent, choosing which Meta ad account to connect, signing a
Data Processing Addendum, paying for a plan, approving a write with real
consequences. A person has to do those in a browser.

A **hand-off** is how your agent gets past them without a browser of its own.
You mint one, hand the URL to your user, and poll until it settles:

1. **Mint** — through the route for the thing you need (see *Where hand-offs
   come from* below). You get back a `handoff_id`.
2. **Hand over the URL** — `GET /api/v1/handoffs/{id}` returns it while the
   hand-off is pending. Send it however you already talk to your user.
3. **Poll** — the same endpoint. When `status` stops being `pending`, continue.

It is the pattern agent hosts already use for a Gmail or Cloudflare consent, and
it is deliberately the same object for all five kinds: one shape to poll, one
expiry to reason about, one place to look when something goes quiet.

## Poll a hand-off

```http title="Endpoint"
GET /api/v1/handoffs/{id}
```

**Scope:** `analytics:read`

The weakest read scope on this surface, on purpose. The *mint* is what needs
authority — starting a connect needs `connections:write` (a key minted under an
older `attribution_write` grant is still accepted during the swap window; see
[connections](/docs/api/connections#start-a-connect-and-hand-it-to-a-human)),
and a checkout needs a billing scope. Asking a question about a hand-off your
workspace already minted needs neither, so a long-lived read key can watch what
a write key started.

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/handoffs/0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
import { AtribuClient } from "@atribu/node";

const client = new AtribuClient({ apiKey: process.env.ATRIBU_API_KEY });
const { data } = await client.handoffs.get(handoffId);

if (data.status === "pending") {
  console.log("Ask your user to open:", data.url);
} else if (data.status === "completed") {
  console.log("Done — carry on.");
}
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

res = requests.get(
    f"https://api.atribu.app/api/v1/handoffs/{handoff_id}",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
)
data = res.json()["data"]
```
</Tab>
</Tabs>

### Response

```json title="Success response (200 OK)"
{
  "data": {
    "id": "0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21",
    "kind": "pick",
    "status": "pending",
    "url": "https://www.atribu.app/h/9tQ2mB1xQhK4vT7nJ0pL9sD3fG6hZ5cW2aY1bN4uM8E",
    "expires_at": "2026-09-19T10:45:00.000Z",
    "created_at": "2026-09-05T10:45:00.000Z",
    "completed_at": null,
    "result": null
  },
  "meta": { "profile_id": "8f3d…" }
}
```

## The three things worth knowing

### An expired hand-off is a `200`, never a `404`

`status` becomes `expired` the moment `expires_at` passes. There is no sweeper
to fall behind it, so there is no window in which a poll answers `pending` for a
link that no longer works — and, more importantly, no ambiguity between *"that
lapsed, mint another"* and *"you asked for the wrong id"*. A `404` here means
the id names nothing your workspace minted: never created, malformed, or
someone else's. Those three are deliberately indistinguishable.

### `url` is returned only while `status` is `pending`

For `connect`, `sign_dpa` and `checkout`, **whoever holds the URL** can complete
the hand-off. That is the design — your user does not need an Atribu account, a
session, or a desktop; a link opened on a phone, signed out, is the ordinary
case. It is also why a settled hand-off stops returning the URL: there is
nothing left to do with it, and handing back a dead link only invites a client
to keep showing one.

<Callout type="warn" title="`approve` is the exception">
An `approve` hand-off needs **a signed-in workspace owner or admin**, and it
must not be the person whose credential requested it. The other three kinds ask
a human to finish an *external* step — grant a consent, accept a DPA, pay — and
holding the link is the only claim they make. `approve` asks for *authority*: it
is minted by the very principal whose write was refused, so if holding the link
were enough, an agent could approve itself.

Opening the URL signed out shows the change and a **Sign in to approve** button
(returning to the same link); the decision endpoint refuses with `401` when
there is no session, `403` when the signed-in user is not an owner/admin of that
workspace, and `403` when they are the requester. A refusal leaves the hand-off
**open**, so the right person can still decide it. `result.completed_by` records
who did.
</Callout>

Capture `url` when you first read it and give it to your user. Re-poll for
`status`, not for the URL.

### There is no `POST /api/v1/handoffs`

A hand-off with nothing behind it is a URL nobody can complete. Each kind is
minted by the route for the thing it hands off, which is the only place that
knows what *finishing* means. This endpoint is the one poll they all share.

## The five kinds

| `kind` | what the human does | minted by |
|---|---|---|
| `connect` | grants a provider OAuth consent (Meta, Google Ads, Search Console, GoHighLevel, Stripe, MercadoPago, Shopify) | the connect hand-off route |
| `pick` | chooses among the assets a consent exposed — today, the Conversion Sync setup link's Meta ad account, pixel and dataset | the Conversion Sync setup-link mint |
| `sign_dpa` | accepts a Data Processing Agreement or the combined DPA & BAA | [`POST /api/v1/legal/{document}/handoff`](/docs/api/legal) |
| `checkout` | pays for a plan. Workspace-level: it names no profile | [`POST /api/v1/workspaces/{id}/checkout-session`](/docs/api/billing) |
| `approve` | approves a consequential write your agent proposed | the approval route |

`pick`, `sign_dpa` and `checkout` are live today. `connect` and `approve` land as
their mint routes ship; a hand-off of a kind whose flow is not wired yet renders a
page that says so plainly rather than a dead link.

The three live kinds send the human to three different places, and the landing
page resolves which: `pick` to the Conversion Sync setup page, `sign_dpa` to the
click-wrap at `/dpa/sign/<handle>`, and `checkout` **straight out to Stripe**.
None of them redirects once the hand-off has settled — a spent Stripe session
renders an error page, and a completed signature has nothing left to sign.

## Statuses

| `status` | meaning |
|---|---|
| `pending` | still open. `url` is present and works. |
| `completed` | the human finished. `result` carries what came of it. |
| `expired` | `expires_at` passed with nothing done. Mint a fresh hand-off. |
| `cancelled` | withdrawn or superseded — typically because a newer hand-off replaced it. |
| `failed` | attempted and could not be finished. `result` says why. |

`result` also carries `completed_by`: the Atribu user id when a signed-in person
finished the hand-off, and `null` when an anonymous link holder did. The `null`
is recorded rather than omitted — "nobody was signed in" is a fact about how the
hand-off completed, not a missing field.

## Expiry

45 minutes by default — the same budget Atribu's own consumer connect detour
ships against, arrived at because five minutes did not survive a real human
working through Meta's Embedded Signup.

A kind whose underlying object has its own clock mirrors that clock instead. A
`pick` hand-off for a Conversion Sync setup link lives as long as the link does
(14 days, extended when the agency asks the client to fix a partial grant), so
you are never told a hand-off expired while your user is looking at a live page.
Always read `expires_at`; never assume the default.

---

# Legal (DPA / BAA)

Before Atribu forwards a customer's conversion to an ad platform, the workspace
has to have accepted a **Data Processing Agreement**. Healthcare Mode
additionally needs a **HIPAA Business Associate Agreement**, which ships as one
combined DPA & BAA click-wrap.

Neither is something a program can do. An API key is a machine credential — and
a delegated key is an agency's app acting inside someone else's workspace — so
an acceptance either of them could have produced would record nothing worth
recording. An MCP user token is refused too, and deliberately: it names a real
person, but it is the credential they minted *for an agent*, so accepting with
one is an agent signing on their behalf. Only a browser session says a person is
present. `POST /api/v1/legal/{document}/accept` refuses all three with a `403`
that names the alternative.

The alternative is a [hand-off](/docs/api/handoffs): a session-less URL you give
to a person, an id you poll, and an expiry.

## Mint a signing hand-off

```http title="Endpoint"
POST /api/v1/legal/dpa/handoff
POST /api/v1/legal/baa/handoff
```

**Scope:** `exports:write` · **Body:** none

<Tabs items={["cURL", "JavaScript"]}>
<Tab value="cURL">
```bash title="cURL"
curl -X POST -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/legal/dpa/handoff"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
import { AtribuClient } from "@atribu/node";

const client = new AtribuClient({ apiKey: process.env.ATRIBU_API_KEY });
const handoff = await client.legal.signDpa();

if (handoff.status === "pending") {
  // Send this to whoever can agree on the customer's behalf.
  console.log("Please sign here:", handoff.url);
} else {
  console.log("Already signed — nothing to do.");
}
```
</Tab>
</Tabs>

```json title="Success response (200 OK)"
{
  "data": {
    "id": "0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21",
    "kind": "sign_dpa",
    "status": "pending",
    "url": "https://www.atribu.app/h/9tQ2mB1x…",
    "expires_at": "2026-09-05T11:30:00.000Z",
    "created_at": "2026-09-05T10:45:00.000Z",
    "completed_at": null,
    "result": null
  },
  "meta": { "profile_id": "8f3d…" }
}
```

The URL opens the same click-wrap the Atribu console shows — the full agreement,
a scroll-to-the-bottom gate, and a name and email for the signer. Accepting
records the signer's email, IP address, user agent, the timestamp and the
document version.

Then poll [`GET /api/v1/handoffs/{id}`](/docs/api/handoffs) until `status` leaves
`pending`, and read the record back from
[`GET /api/v1/conversion-sync/legal`](/docs/api/conversion-sync).

### Already signed? You get `completed`, not an error

A hand-off minted for a document that is already accepted comes back settled, in
the same call:

```json title="Already accepted"
{
  "data": {
    "kind": "sign_dpa",
    "status": "completed",
    "url": null,
    "result": {
      "already_accepted": true,
      "document": "dpa",
      "document_version": "2026-09-02-v1",
      "accepted_at": "2026-08-14T09:12:44.000Z"
    }
  }
}
```

This is the property to build on: re-running a setup checklist must never ask a
customer to sign the same agreement twice, and it never will. `url` is `null`
because there is nothing for anyone to open.

### The DPA and the BAA are separate

They write different columns, and that separation is the point: accepting the
DPA can never be mistaken for a BAA approval, which is what unlocks Healthcare
Mode. A signed DPA does not settle a BAA hand-off, and asking for `/baa/handoff`
on a profile that has only signed the DPA gives you a live URL, correctly.

## Accept as a signed-in owner or admin

```http title="Endpoint"
POST /api/v1/legal/dpa/accept
POST /api/v1/legal/baa/accept
```

**Scope:** `exports:write` · **Credential:** a Supabase session bearer, held by a
workspace `owner` or `admin`

This is the Atribu console's own write, published so acceptance has one
implementation rather than several. It records the agreement **for the person
making the call**.

```json title="Optional body"
{ "document_version": "2026-09-02-v1" }
```

The one field names which of the shipped texts you displayed — the full
agreement at `/dpa`, or the shorter platform-safe click-wrap that gates a
profile's privacy mode. It is checked against an allowlist and never recorded as
given: the stored version says *which* agreement a customer accepted, and it
cannot do that job if the customer picks the value. Omit it to accept the
current text.

```json title="Success response (200 OK)"
{
  "data": {
    "document": "dpa",
    "document_version": "2026-09-02-v1",
    "accepted_at": "2026-09-05T10:45:12.000Z",
    "already_accepted": false
  },
  "meta": { "profile_id": "8f3d…" }
}
```

**Idempotent at the document grain.** Accepting an already-accepted document
writes nothing, answers `already_accepted: true`, and returns the ORIGINAL
timestamp — a reload is not a second agreement.

### Why an API key gets a 403 here

```json title="403 insufficient_scope"
{
  "error": {
    "code": "insufficient_scope",
    "message": "A DPA or BAA can only be accepted by a signed-in workspace owner or admin. An API key cannot agree to a contract on a customer's behalf — mint a signing hand-off with POST /api/v1/legal/dpa/handoff and give the URL to a person.",
    "status": 403
  }
}
```

A guest who reaches the profile through a profile membership alone is refused for
the same reason, however wide their profile permissions: they are not the
counterparty to Atribu's agreement.

## The DPA for a partner app

A partner app that provisions profiles for its own customers
(`POST /api/v1/profiles`, [Partners](/docs/api/partners)) creates **headless**
profiles: their owners never sign in to Atribu, so nobody could accept the DPA
for them. For those profiles the DPA counterparty is the partner, so the partner
accepts it **once, for the app**.

- **Where.** An `owner` or `admin` of the workspace that administers the app
  (`owner_workspace_id`, set by Atribu when the app is registered) opens
  **Workspace settings → Compliance** in the console. The "Partner apps" card
  lists each app with its DPA state and how many of its profiles the acceptance
  covers, and shows the same Platform-Safe click-wrap as the profile gate.
- **What it writes.** In one transaction, every profile the app provisioned gets
  `dpa_status: accepted` with the app's document version, timestamp and
  acceptor. Every profile the app provisions later is accepted at birth, and
  every idempotent `POST /api/v1/profiles` replay re-applies it. The wiring
  read's `platform_safe.dpa_status`, the provision `checks.dpa_status` and the
  export legal gate all read the same field, so `sign_dpa` stops appearing for
  those profiles.
- **Direct acceptances win.** A profile whose own owner already accepted the DPA
  keeps that acceptance — its version, timestamp and signer — and is never
  overwritten by the app.
- **Withdrawing.** The same card withdraws it. Every profile that held the DPA
  through the app returns to `dpa_status: withdrawn`, and Atribu holds its
  Platform-Safe exports again; direct acceptances are untouched.
- **Credentials.** A browser session only. An API key never reaches these
  routes, and an MCP user token is refused with `403 insufficient_scope`, for
  the reason given at the top of this page.

```http title="Endpoints"
GET  /api/v1/workspaces/{workspaceId}/oauth-apps
POST /api/v1/workspaces/{workspaceId}/oauth-apps/{appId}/dpa/accept
POST /api/v1/workspaces/{workspaceId}/oauth-apps/{appId}/dpa/withdraw
```

`accept` takes the same optional `{ "document_version": "…" }` body as the
profile accept and is idempotent the same way: accepting an accepted app answers
`changed: false` and keeps the original `dpa_accepted_at`. Operations read the
app's state on `GET /api/v1/admin/oauth-apps/{id}` (`dpa_status`, `dpa_version`,
`dpa_accepted_at`, `dpa_accepted_by`, read only).

## Reading the result

`GET /api/v1/conversion-sync/legal` (scope `exports:read`) returns the full
compliance record: `dpa_status`, `baa_status`, `signature_status`, `signed_at`,
the document version, and every stored document with a time-limited signed URL.
That is where the signer's name, email, IP and user agent live — on the
evidence row rather than on the compliance columns, because the signatory
usually has no Atribu account for those columns' foreign keys to point at.

---

# Billing

Two calls. `GET .../subscription` tells you what plan a workspace is on, how
close it is to its limits, and what can be sold to it.
`POST .../checkout-session` mints a [hand-off](/docs/api/handoffs) carrying a
Stripe Checkout URL for a person to pay through.

Read the first before calling the second. Its `limits` are how you tell a human
*why* they need an upgrade instead of asking them to pay for a reason they
cannot see.

## Read the subscription

```http title="Endpoint"
GET /api/v1/workspaces/{workspaceId}/subscription
```

**Scope:** `analytics:read` · **Credential:** a Supabase session bearer

```json title="Success response (200 OK)"
{
  "data": {
    "plan_tier": "starter",
    "billing_interval": null,
    "status": "active",
    "billing_provider": "free",
    "current_period_start": null,
    "current_period_end": null,
    "trial_started_at": null,
    "trial_ends_at": null,
    "cancel_at_period_end": false,
    "canceled_at": null,
    "limits": {
      "active_profiles": { "used": 1, "max": 1 }
    },
    "upgrade_available": [
      {
        "plan": "growth",
        "currency": "USD",
        "price": "99",
        "annual_monthly_price": "79",
        "checkout": { "method": "POST", "path": "/api/v1/workspaces/{workspaceId}/checkout-session" }
      },
      {
        "plan": "agency",
        "currency": "USD",
        "price": "349",
        "annual_monthly_price": "279",
        "checkout": { "method": "POST", "path": "/api/v1/workspaces/{workspaceId}/checkout-session" }
      }
    ]
  },
  "meta": { "workspace_id": "2a87…" }
}
```

`limits.active_profiles` carries the same `used` / `max` numbers
`POST /api/v1/profiles` compares before answering `403 profile_limit_reached`,
so you see the ceiling coming rather than discovering it at a denial. `max` is
`null` when an override makes the cap moot — the workspace can never actually be
denied on that limit, so a finite number there would be a ceiling it cannot hit.

`upgrade_available` lists only what is **above** the current tier, cheapest
first. An empty array is a real answer with three possible meanings — already on
the top sellable plan, on a tier this catalogue does not rank (`enterprise`, whose
price is negotiated), or nothing above it. None of them should render an upgrade
prompt.

`price` is the monthly **list** price and `annual_monthly_price` the per-MONTH
rate when billed annually — the discounted figure the pricing page shows, not
the yearly total. Both are exact decimal strings in major units (ADR 0018);
parse before doing arithmetic.

They are LIST prices, not this workspace's effective price: a promotion code or
a legacy price is settled at Checkout, and quoting a number this row cannot know
would be worse than quoting the public one.

`data` is `null` — not a `404` — when the workspace has no subscription row at
all. That is a real state for a workspace that has never started a plan.

## Mint a checkout hand-off

```http title="Endpoint"
POST /api/v1/workspaces/{workspaceId}/checkout-session
```

**Scope:** `analytics:read` · **Credential:** a Supabase session bearer held by a
workspace `owner` or `admin`

```json title="Request body"
{ "plan": "growth", "interval": "monthly" }
```

`plan` is `growth` or `agency`. `interval` is `monthly` (the default) or
`annual` — an agent asking for "the Growth plan" has expressed a complete
intent, and requiring a cadence would turn a one-argument hand-off into a
two-argument one for no gain.

<Tabs items={["cURL", "JavaScript"]}>
<Tab value="cURL">
```bash title="cURL"
curl -X POST -H "Authorization: Bearer SESSION_TOKEN" \
  -H "content-type: application/json" \
  -d '{"plan":"growth"}' \
  "https://api.atribu.app/api/v1/workspaces/2a87dfaf-…/checkout-session"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
const sub = await client.workspaces.subscription(workspaceId);
const [next] = sub?.upgrade_available ?? [];

if (next) {
  const handoff = await client.workspaces.startCheckout(workspaceId, { plan: next.plan });
  if (handoff.status === "pending") {
    console.log("Ask them to pay here:", handoff.url);
  }
}
```
</Tab>
</Tabs>

You get the same hand-off object every other kind returns. Give `url` to the
person who can pay; poll `GET /api/v1/handoffs/{id}` until `status` settles.

```json title="Completed, after they pay"
{
  "data": {
    "kind": "checkout",
    "status": "completed",
    "url": null,
    "completed_at": "2026-09-05T11:02:31.000Z",
    "result": { "subscription": { "tier": "growth", "status": "active" } }
  }
}
```

Stripe's webhook is what settles it, so `result.subscription` reports the row
your next `GET .../subscription` will actually read — not the one that was there
a moment before the sync. An abandoned checkout expires and the hand-off is
`failed`, with `result.error: "checkout_expired"`.

## Four things worth knowing

### Already on that plan? `completed`, with `no_change`

```json title="No change"
{
  "data": {
    "kind": "checkout",
    "status": "completed",
    "url": null,
    "result": { "no_change": true, "plan": "growth", "subscription": { "tier": "growth", "status": "active" } }
  }
}
```

Never a second subscription. A workspace **mid-trial** on that plan counts as
being on it: it already holds that subscription, and buying it again would open
a second one rather than convert the trial.

### API keys cannot call this

The route is workspace-scoped, and an `atb_live_` key is minted for exactly one
profile — it has no workspace membership to check, so `403`. That is a property
of what a key *is*, not a scope anyone could grant. A signed-in session and an
MCP user token (`atb_user_`) both reach it — each names a PERSON with a
workspace role — and the MCP `start_plan_upgrade` tool rides this same route.

The caller must be an `owner` or an `admin`. An `analyst` holds the same API
scopes and is still refused: reading numbers is not committing the workspace to a
recurring charge.

### Downgrades are not a checkout

A lower tier is a Stripe Customer Portal change. Opening a second subscription
for it would double-bill, which is why `upgrade_available` lists only what sits
above the current plan.

### The Checkout URL is never handed back after settling

`url` on the hand-off is Atribu's own `/h/<handle>` link, which redirects to
Stripe while the hand-off is open and stops resolving once it is not. A spent
Stripe session renders an error page; a client that kept showing the link would
be advertising a dead payment.

## Errors

| status | code | when |
|---|---|---|
| `403` | `insufficient_scope` | the credential is an API key, or the caller is not an owner/admin |
| `404` | `not_found` | the workspace does not exist, or you are not an active member |
| `422` | `validation_error` | unknown `plan` or `interval` |
| `502` | `provider_error` | Stripe refused — most often the plan's price does not exist in this Stripe mode. The message names the missing lookup key. |
| `503` | `service_unavailable` | billing is not configured on this deployment; no session was created |

A `502` also **fails** the hand-off rather than leaving it pending: a hand-off
with no URL behind it can only ever expire, and an agent polling it deserves the
error now.

---

# Overview

```http title="Endpoint"
GET /api/v1/overview
```

Returns KPIs for the current period and an automatically-computed previous period of equal length for comparison.

**Scope:** `analytics:read`

## Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `date_from` | string | Yes | Start date (YYYY-MM-DD) |
| `date_to` | string | Yes | End date (YYYY-MM-DD) |
| `model` | string | No | Attribution model (default: `last_touch`) |
| `filter[dimension]` | string | No | Filter by dimension, e.g., `filter[channel]=is:Paid Social` |

<Callout type="info" title="Attribution models">
`last_touch` · `first_touch` · `linear` · `time_decay` · `last_non_direct`
</Callout>

## Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="Request"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/overview?date_from=2026-03-01&date_to=2026-03-25&model=last_touch"
```
</Tab>
<Tab value="JavaScript">
```ts title="Request"
const response = await fetch(
  "https://www.atribu.app/api/v1/overview?date_from=2026-03-01&date_to=2026-03-25&model=last_touch",
  {
    headers: {
      Authorization: "Bearer atb_live_YOUR_KEY",
    },
  }
);
const data = await response.json();
```
</Tab>
<Tab value="Python">
```python title="Request"
import requests

resp = requests.get(
    "https://www.atribu.app/api/v1/overview",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={
        "date_from": "2026-03-01",
        "date_to": "2026-03-25",
        "model": "last_touch",
    },
)
data = resp.json()
```
</Tab>
</Tabs>

## Response

```json title="Success response (200 OK)"
{
  "data": {
    "current": {
      "spend": 4250.00,
      "revenue": 12800.00,
      "roas": 3.01,
      "outcomes": 145,
      "attributed_outcomes": 132,
      "coverage_percent": 91.03,
      "visitors": 8420,
      "pageviews": 24100,
      "bounce_rate": 42.5,
      "avg_engaged_seconds": 185,
      "cash_revenue": 12800.00,
      "cash_payments": 48
    },
    "previous": {
      "spend": 3900.00,
      "revenue": 10200.00,
      "roas": 2.62,
      "outcomes": 128,
      "attributed_outcomes": 115,
      "coverage_percent": 89.84,
      "visitors": 7200,
      "pageviews": 20500,
      "bounce_rate": 45.2,
      "avg_engaged_seconds": 165,
      "cash_revenue": 10200.00,
      "cash_payments": 38
    }
  },
  "meta": {
    "date_from": "2026-03-01",
    "date_to": "2026-03-25",
    "profile_id": "uuid"
  }
}
```

## Response fields

| Field | Type | Description |
|-------|------|-------------|
| `spend` | number | Total ad spend in reporting currency |
| `revenue` | number | Attributed cash revenue (ROAS numerator) |
| `roas` | number | Return on ad spend (revenue / spend) |
| `outcomes` | number | Total conversions |
| `attributed_outcomes` | number | Conversions with at least one attributed touchpoint |
| `coverage_percent` | number | Attribution coverage (attributed / total x 100) |
| `visitors` | number | Unique visitors (distinct anonymous_id) |
| `pageviews` | number | Total page views |
| `bounce_rate` | number | Single-page session percentage |
| `avg_engaged_seconds` | number | Average engaged time per session |
| `cash_revenue` | number | Total cash payments (Stripe + MercadoPago) |
| `cash_payments` | number | Count of cash payment events |

<Callout type="info" title="Previous period">
The previous period is automatically computed to be the same length as the current period, ending the day before `date_from`. For example, if you query March 1-25 (25 days), the previous period will be February 4-28.
</Callout>

## Deprecation policy

When an endpoint on this API is superseded, it keeps working for **at least 90 days** after the change is announced. During that window every response from the deprecated endpoint carries a `Deprecation: @<unix-seconds>` header (the date the notice went out) and, once a removal date is set, a `Sunset: <HTTP-date>` header naming it (RFC 8594) plus a `Link: <successor-path>; rel="successor-version"` pointing at the replacement. Nothing is removed with less notice than that, and every deprecation is also logged in the [API changelog](https://github.com/AtribuCore/atribu/blob/main/docs/api-changelog.md) with its sunset date. `POST /api/v1/goals` is the first endpoint on this policy, superseded by `POST /api/v1/goals/definitions`.

## Related

<Cards>
  <Card title="Authentication" href="/docs/api/authentication">
    API key setup, scopes, and rate limits
  </Card>
  <Card title="Attribution Models" href="/docs/concepts/attribution-models">
    How the model parameter affects revenue and ROAS
  </Card>
  <Card title="Revenue Types" href="/docs/concepts/revenue-types">
    Why revenue fields only include cash payments
  </Card>
</Cards>

---

# Timeseries

```http title="Endpoint"
GET /api/v1/timeseries
```

Returns daily data points for visitors, pageviews, bounces, cash revenue, and ad spend across
the requested date range. Use this endpoint to power line charts, bar charts, and trend
comparisons in your dashboards.

**Scope:** `analytics:read`

## Parameters

| Parameter            | Type   | Required | Description                                                       |
| -------------------- | ------ | -------- | ----------------------------------------------------------------- |
| `date_from`          | string | Yes      | Start date in `YYYY-MM-DD` format.                                |
| `date_to`            | string | Yes      | End date in `YYYY-MM-DD` format.                                  |
| `filter[dimension]`  | string | No       | Filter results by a dimension. See [Breakdowns](/docs/api/breakdowns#filtering) for syntax. |

## Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/timeseries?date_from=2026-03-20&date_to=2026-03-25"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
const res = await fetch(
  "https://www.atribu.app/api/v1/timeseries?date_from=2026-03-20&date_to=2026-03-25",
  { headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
const { data, meta } = await res.json();
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

res = requests.get(
    "https://www.atribu.app/api/v1/timeseries",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={"date_from": "2026-03-20", "date_to": "2026-03-25"},
)
data = res.json()["data"]
```
</Tab>
</Tabs>

## Response

```json title="Success response (200 OK)"
{
  "data": [
    {
      "date": "2026-03-20",
      "visitors": 1250,
      "pageviews": 3600,
      "bounces": 520,
      "cash_revenue": 2400.00,
      "spend": 680.00
    },
    {
      "date": "2026-03-21",
      "visitors": 1180,
      "pageviews": 3400,
      "bounces": 490,
      "cash_revenue": 1800.00,
      "spend": 720.00
    },
    {
      "date": "2026-03-22",
      "visitors": 980,
      "pageviews": 2800,
      "bounces": 410,
      "cash_revenue": 3200.00,
      "spend": 650.00
    }
  ],
  "meta": {
    "date_from": "2026-03-20",
    "date_to": "2026-03-25",
    "profile_id": "uuid"
  }
}
```

### Response fields

| Field          | Type   | Description                                          |
| -------------- | ------ | ---------------------------------------------------- |
| `date`         | string | Calendar date (`YYYY-MM-DD`).                        |
| `visitors`     | number | Unique visitors (distinct `anonymous_id`) that day.  |
| `pageviews`    | number | Total page views recorded.                           |
| `bounces`      | number | Sessions with a single page view.                    |
| `cash_revenue` | number | Attributed cash revenue (Stripe / MercadoPago) in your reporting currency. |
| `spend`        | number | Total ad spend across all connected platforms.        |

<Callout type="info" title="Revenue is cash only">
  `cash_revenue` includes confirmed payments from Stripe and MercadoPago only.
  GHL pipeline values (deal stages, closed-won projections) are excluded because
  they are not confirmed cash. See [Conversions & Revenue](/docs/api/conversions) for
  the full breakdown.
</Callout>

## Related

<Cards>
  <Card title="Authentication" href="/docs/api/authentication">
    API key setup and scopes
  </Card>
  <Card title="Dashboard" href="/docs/features/dashboard">
    The main chart uses the same data this endpoint returns
  </Card>
</Cards>

---

# Breakdowns

Seven breakdown endpoints share the same response shape. Each returns dimension
values ranked by visitors, along with traffic and conversion metrics.

**Scope:** `analytics:read`

## Endpoints

<Cards>
  <Card title="GET /api/v1/channels" href="#request">
    Traffic source — Paid Social, Organic Search, Direct, etc.
  </Card>
  <Card title="GET /api/v1/pages" href="#request">
    Page path — top landing and content pages.
  </Card>
  <Card title="GET /api/v1/countries" href="#request">
    Country code — ISO 3166-1 alpha-2.
  </Card>
  <Card title="GET /api/v1/devices" href="#request">
    Device type — desktop, mobile, tablet.
  </Card>
  <Card title="GET /api/v1/browsers" href="#request">
    Browser name — Chrome, Safari, Firefox, etc.
  </Card>
  <Card title="GET /api/v1/os" href="#request">
    Operating system — Windows, macOS, iOS, Android, etc.
  </Card>
  <Card title="GET /api/v1/referrers" href="#request">
    Referrer domain — the site that sent the visitor.
  </Card>
</Cards>

## Parameters

| Parameter           | Type   | Required | Description                                                              |
| ------------------- | ------ | -------- | ------------------------------------------------------------------------ |
| `date_from`         | string | Yes      | Start date in `YYYY-MM-DD` format.                                       |
| `date_to`           | string | Yes      | End date in `YYYY-MM-DD` format.                                         |
| `limit`             | number | No       | Maximum results to return. Default `10`, max `100`.                      |
| `filter[dimension]` | string | No       | Filter by any supported dimension. See [Filtering](#filtering) below.    |

## Request

All seven endpoints accept the same parameters and return the same shape. The
examples below use `/api/v1/channels`.

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/channels?date_from=2026-03-01&date_to=2026-03-25&limit=5"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
const res = await fetch(
  "https://www.atribu.app/api/v1/channels?date_from=2026-03-01&date_to=2026-03-25&limit=5",
  { headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
const { data, meta } = await res.json();
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

res = requests.get(
    "https://www.atribu.app/api/v1/channels",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={"date_from": "2026-03-01", "date_to": "2026-03-25", "limit": 5},
)
data = res.json()["data"]
```
</Tab>
</Tabs>

## Response

```json title="Success response (200 OK)"
{
  "data": [
    {
      "value": "Paid Social",
      "visitors": 3200,
      "visits": 4100,
      "pageviews": 12500,
      "bounce_rate": 38.5,
      "conversions": 85,
      "conversion_rate": 2.07,
      "revenue": 8500.00
    },
    {
      "value": "Organic Search",
      "visitors": 2100,
      "visits": 2800,
      "pageviews": 7200,
      "bounce_rate": 48.2,
      "conversions": 32,
      "conversion_rate": 1.14,
      "revenue": 3200.00
    }
  ],
  "meta": {
    "date_from": "2026-03-01",
    "date_to": "2026-03-25",
    "profile_id": "uuid"
  }
}
```

### Response fields

| Field             | Type   | Description                                                              |
| ----------------- | ------ | ------------------------------------------------------------------------ |
| `value`           | string | The dimension value (channel name, page path, country code, etc.).       |
| `visitors`        | number | Unique visitors.                                                         |
| `visits`          | number | Total sessions (visits).                                                 |
| `pageviews`       | number | Total page views.                                                        |
| `bounce_rate`     | number | Percentage of single-page sessions.                                      |
| `conversions`     | number | Total conversions attributed to this dimension value.                    |
| `conversion_rate` | number | Conversions divided by visitors, as a percentage.                        |
| `revenue`         | number | Attributed cash revenue in your reporting currency.                      |

## Filtering

Apply filters to any breakdown endpoint using the `filter[dimension]=operator:value`
query parameter syntax. Multiple filters can be combined.

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL — filter countries by Paid Social traffic"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/countries?date_from=2026-03-01&date_to=2026-03-25&filter[channel]=is:Paid+Social"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript — filter countries by Paid Social traffic"
const url = new URL("https://www.atribu.app/api/v1/countries");
url.searchParams.set("date_from", "2026-03-01");
url.searchParams.set("date_to", "2026-03-25");
url.searchParams.set("filter[channel]", "is:Paid Social");

const res = await fetch(url, {
  headers: { Authorization: "Bearer atb_live_YOUR_KEY" },
});
```
</Tab>
<Tab value="Python">
```python title="Python — filter countries by Paid Social traffic"
res = requests.get(
    "https://www.atribu.app/api/v1/countries",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={
        "date_from": "2026-03-01",
        "date_to": "2026-03-25",
        "filter[channel]": "is:Paid Social",
    },
)
```
</Tab>
</Tabs>

### Supported operators

| Operator       | Description                                  | Example                              |
| -------------- | -------------------------------------------- | ------------------------------------ |
| `is`           | Exact match.                                 | `filter[country]=is:US`              |
| `is_not`       | Exclude exact match.                         | `filter[device]=is_not:desktop`      |
| `contains`     | Substring match (case-insensitive).          | `filter[page]=contains:/pricing`     |
| `not_contains` | Exclude substring match (case-insensitive).  | `filter[referrer]=not_contains:spam` |

### Available filter dimensions

`channel`, `referrer`, `campaign`, `page`, `entry_page`, `country`, `region`,
`city`, `browser`, `os`, `device`, `goal`.

<Callout type="info" title="Filters apply across endpoints">
  Filters work identically on all seven breakdown endpoints as well as on
  the [Timeseries](/docs/api/timeseries) endpoint. For example,
  `filter[channel]=is:Direct` on `/api/v1/pages` shows only pages visited
  by direct traffic.
</Callout>

## Related

<Cards>
  <Card title="Authentication" href="/docs/api/authentication">
    API key setup and scopes
  </Card>
  <Card title="Channel Classification" href="/docs/concepts/channels">
    How channels are detected from UTMs, referrers, and click IDs
  </Card>
</Cards>

---

# Campaigns

## Top Campaigns

```http title="Endpoint"
GET /api/v1/campaigns
```

Returns top-performing campaigns ranked by attributed outcome value, with ROAS
and spend data. Pass `level` to drill down to ad sets or individual ads.

**Scope:** `campaigns:read`

### Parameters

| Parameter   | Type   | Required | Description                                                                 |
| ----------- | ------ | -------- | --------------------------------------------------------------------------- |
| `date_from` | string | Yes      | Start date in `YYYY-MM-DD` format.                                          |
| `date_to`   | string | Yes      | End date in `YYYY-MM-DD` format.                                            |
| `model`     | string | No       | Attribution model. Default `last_touch`. Options: `first_touch`, `linear`, `time_decay`, `last_non_direct`. |
| `limit`     | number | No       | Maximum results. Default `10`, max `100`.                                   |
| `level`     | string | No       | Hierarchy level: `campaign`, `ad_set`, or `ad`. When set, returns detailed performance fields. |

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/campaigns?date_from=2026-03-01&date_to=2026-03-25&model=last_touch&limit=5"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
const res = await fetch(
  "https://www.atribu.app/api/v1/campaigns?date_from=2026-03-01&date_to=2026-03-25&model=last_touch&limit=5",
  { headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
const { data, meta } = await res.json();
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

res = requests.get(
    "https://www.atribu.app/api/v1/campaigns",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={
        "date_from": "2026-03-01",
        "date_to": "2026-03-25",
        "model": "last_touch",
        "limit": 5,
    },
)
data = res.json()["data"]
```
</Tab>
</Tabs>

### Response

```json title="Success response (200 OK)"
{
  "data": [
    {
      "campaign_id": "uuid",
      "campaign_name": "Spring Sale - Conversions",
      "spend": 1800.00,
      "outcome_count": 45,
      "outcome_value": 6200.00,
      "roas": 3.44,
      "attributed_outcome_count": 189,
      "attributed_outcomes_by_type": {
        "appointment_attended": 2,
        "appointment_booked": 4,
        "closed_won": 1,
        "dm_started": 89,
        "lead_created": 88,
        "payment_received": 2,
        "quote_presented": 3
      }
    }
  ],
  "meta": {
    "date_from": "2026-03-01",
    "date_to": "2026-03-25",
    "profile_id": "uuid"
  }
}
```

### Response fields

| Field           | Type   | Description                                                          |
| --------------- | ------ | -------------------------------------------------------------------- |
| `campaign_id`   | string | Internal campaign UUID.                                              |
| `campaign_name` | string | Human-readable campaign name from the ad platform.                   |
| `spend`         | number | Total ad spend in your reporting currency.                           |
| `outcome_count` | number | **Cash** conversions (payments) credited to this campaign under `model`. The ROAS/CAC denominator. |
| `outcome_value` | number | Total attributed cash revenue.                                       |
| `roas`          | number | Return on ad spend (`outcome_value / spend`).                        |
| `attributed_outcome_count` | number | Conversions of **every kind** credited to this campaign under `model` — leads, DMs started, appointments, quotes, closed deals and cash payments (refunds excluded). **Not cash**: never use it for ROAS or CAC. |
| `attributed_outcomes_by_type` | object | `attributed_outcome_count` split by conversion type (`{"dm_started": 89, "lead_created": 88, …}`). Values sum to `attributed_outcome_count`; a type with nothing credited is absent. |

<Callout type="info" title="Lead-gen and messaging accounts">
  `outcome_count` counts cash only, so a clinic that books and closes through
  its CRM or WhatsApp and never takes payment through Atribu sees `0` there.
  `attributed_outcome_count` and its `attributed_outcomes_by_type` split are
  the funnel for those accounts.

  Every count is **conversions credited to this entity under the model**: a
  conversion counts for a campaign only if that campaign received credit, not
  merely because a touch on it appeared somewhere in the path. Under a
  single-credit model (`last_touch`, `first_touch`, `last_non_direct`) the rows
  plus `GET /api/v1/campaigns/unattributed` add up to the profile's
  total; under `linear` or `time_decay` a conversion credited to two campaigns
  counts under both. Compare counts only within one model and never add one
  model's figures to another's.
</Callout>

### Ad-level detail

Pass `level=ad` to get granular performance per ad creative. This adds
additional fields to each row.

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL — ad-level breakdown"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/campaigns?date_from=2026-03-01&date_to=2026-03-25&level=ad"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript — ad-level breakdown"
const res = await fetch(
  "https://www.atribu.app/api/v1/campaigns?date_from=2026-03-01&date_to=2026-03-25&level=ad",
  { headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
const { data } = await res.json();
```
</Tab>
<Tab value="Python">
```python title="Python — ad-level breakdown"
res = requests.get(
    "https://www.atribu.app/api/v1/campaigns",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={"date_from": "2026-03-01", "date_to": "2026-03-25", "level": "ad"},
)
data = res.json()["data"]
```
</Tab>
</Tabs>

#### Additional fields when `level` is set

| Field          | Type   | Description                                          |
| -------------- | ------ | ---------------------------------------------------- |
| `entity_level` | string | The level of this row (`campaign`, `ad_set`, `ad`).  |
| `parent_name`  | string | Name of the parent entity (campaign or ad set).      |
| `impressions`  | number | Total impressions served.                            |
| `clicks`       | number | Total clicks.                                        |
| `reach`        | number | Unique accounts reached.                             |
| `ctr`          | number | Click-through rate (%).                              |
| `avg_cpm`      | number | Average cost per 1,000 impressions.                  |
| `avg_cpc`      | number | Average cost per click.                              |
| `cac`          | number | Customer acquisition cost (`spend / outcome_count`). |
| `status`       | string | Entity status (`ACTIVE`, `PAUSED`, etc.).            |
| `objective`    | string | Campaign objective.                                  |
| `daily_budget` | number | Daily budget if set on the platform.                 |
| `campaign_external_id` / `ad_set_external_id` | string \| null | The parent campaign / ad set ids, for drilling campaign → ad set → ad. |
| `messaging_conversations_started_7d` | number | Conversations started (7-day window) as reported by the platform. |
| `attributed_outcome_count` / `attributed_outcomes_by_type` | number / object | Conversions of every kind credited to this entity under `model` (not cash), and their split by type — see above. Also on `GET /api/v1/ads/{id}` and on the `GET /api/v1/campaigns/unattributed` remainder. |

---

## Campaign Trend

```http title="Endpoint"
GET /api/v1/campaigns/trend
```

Returns daily spend, impressions, and clicks for up to 10 specific campaigns,
ad sets, or ads. Use this to render sparklines or compare entity performance
over time.

**Scope:** `campaigns:read`

### Parameters

| Parameter    | Type   | Required | Description                                                              |
| ------------ | ------ | -------- | ------------------------------------------------------------------------ |
| `date_from`  | string | Yes      | Start date in `YYYY-MM-DD` format.                                       |
| `date_to`    | string | Yes      | End date in `YYYY-MM-DD` format.                                         |
| `entity_ids` | string | Yes      | Comma-separated list of entity UUIDs (max 10).                           |
| `level`      | string | No       | `campaign`, `ad_set`, or `ad`. Default `campaign`.                       |

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/campaigns/trend?date_from=2026-03-20&date_to=2026-03-25&entity_ids=uuid1,uuid2&level=campaign"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
const ids = ["uuid1", "uuid2"].join(",");
const res = await fetch(
  `https://www.atribu.app/api/v1/campaigns/trend?date_from=2026-03-20&date_to=2026-03-25&entity_ids=${ids}&level=campaign`,
  { headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
const { data } = await res.json();
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

res = requests.get(
    "https://www.atribu.app/api/v1/campaigns/trend",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={
        "date_from": "2026-03-20",
        "date_to": "2026-03-25",
        "entity_ids": "uuid1,uuid2",
        "level": "campaign",
    },
)
data = res.json()["data"]
```
</Tab>
</Tabs>

### Response

```json title="Success response (200 OK)"
{
  "data": [
    { "entity_id": "uuid1", "date": "2026-03-20", "spend": 120.00, "impressions": 15000, "clicks": 340 },
    { "entity_id": "uuid1", "date": "2026-03-21", "spend": 135.00, "impressions": 16200, "clicks": 380 },
    { "entity_id": "uuid2", "date": "2026-03-20", "spend": 95.00, "impressions": 11800, "clicks": 275 }
  ],
  "meta": {
    "date_from": "2026-03-20",
    "date_to": "2026-03-25",
    "profile_id": "uuid"
  }
}
```

### Response fields

| Field         | Type   | Description                          |
| ------------- | ------ | ------------------------------------ |
| `entity_id`   | string | The campaign, ad set, or ad UUID.    |
| `date`        | string | Calendar date (`YYYY-MM-DD`).        |
| `spend`       | number | Ad spend for that entity on that day.|
| `impressions` | number | Impressions served.                  |
| `clicks`      | number | Clicks recorded.                     |

<Callout type="info" title="Entity ID format">
  `entity_ids` expects Atribu internal UUIDs (from the `campaign_id` field in
  the Top Campaigns response), not platform-specific IDs. You can pass up to
  10 IDs in a single request.
</Callout>

---

## Top Performers

```http title="Endpoint"
GET /api/v1/top-performers
```

The profile's best-performing ads, scored against *comparable* creative — a
**cohort** = channel × format × objective × audience warmth × geo × placement.
The score is **cohort-normalized**, **empirical-Bayes-smoothed** (a small-sample
ad shrinks toward the cohort mean — it lands mid-pack, the honest "we don't
know yet"), and **maturity-staged**. So a $500/mo clinic and a $30k/mo clinic
are ranked apples-to-apples.

Each ad carries **three distinct measures — never merge them**:

- `composite_score` (0–100) — a transparent rule-based blend of the ad's
  within-cohort percentiles across funnel layers. The headline rank.
- `top_performer_likelihood` — a **learned field** `{ value, status, reason }`:
  the LambdaMART ranker's 0–1 *probability* (a likelihood, not a guarantee),
  `served` only when a ranker has shown out-of-time skill above chance **and**
  scored this ad; `withheld` otherwise (show «not evaluated», never 0) and
  `not_applicable` on a `rules` row. Read `value` only when `status` is
  `served`. It is **not** ROAS and **not** lift. The Cox fatigue values
  (`hazard_30d_*`, `fatigue_risk_tier`, `expected_lifespan_days`) have the same shape.
- `attributed_revenue` / `roas` (and `attributed_outcomes_total` / `attributed_pipeline_outcomes` /
  `attributed_pipeline_value`) — *real Atribu-attributed outcomes*. `truth_grade='attributed'`
  means Atribu deterministically attributed **≥1 outcome of any kind** to this ad (cash payments
  *or* pipeline conversions — leads, appointments, closed-won). `meta_reported_conversions` /
  `meta_reported_conversion_value` are a separate, lower-confidence signal — Meta's *own* last-click
  count for the ad, **not** Atribu's chain; they never set `truth_grade`.

`primary_outcome_kind` ∈ `cash` · `pipeline` · `messaging` · `meta` · `none` tells you which of
the above to *headline* for a given ad (a lead-gen ad shows pipeline outcomes, a Messages ad shows
conversations started, an ad with only Meta-reported conversions shows `meta_reported_conversions`
labeled as Meta's attribution, `none` = no attributed outcome yet → use `composite_score`).

`maturity_stage` (`cold` → `early` → `mature` → `calibrated`) says how settled
the score is — act on `mature`/`calibrated`, treat `cold` as provisional.
`reason_codes` explain *why* an ad ranks (which funnel layer is strong or weak,
each with a `sample_confidence` band). Scores refresh on a daily cadence.

**Scope:** `campaigns:read`

### Parameters

| Parameter      | Type    | Required | Description                                                                                  |
| -------------- | ------- | -------- | -------------------------------------------------------------------------------------------- |
| `window`       | string  | No       | Rolling lookback: `7d`, `14d`, `28d` (default), or `lifetime`. Not a custom date range.       |
| `limit`        | number  | No       | Maximum ads. Default `10`, max `50`.                                                          |
| `min_spend`    | number  | No       | Only ads with at least this much spend in the window.                                         |
| `objectives`   | string  | No       | Comma-separated cohort objectives to keep (`messaging`, `sales`, `leads`, `traffic`, `awareness`). |
| `truth_grades` | string  | No       | Comma-separated: `predicted`, `attributed`, `lift`.                                           |
| `outcome_kinds`| string  | No       | Comma-separated headline-outcome kinds to keep: `cash`, `pipeline`, `messaging`, `meta`, `none`. |
| `formats`      | string  | No       | Comma-separated creative formats (`ugc_video`, `talking_head`, `static`, `carousel`, …).      |
| `has_video`    | boolean | No       | `true` to keep only ads with a video creative; `false` for only static.                       |

### Request

```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/top-performers?window=28d&limit=5&truth_grades=attributed"
```

### Response

```json title="Success response (200 OK)"
{
  "data": [
    {
      "ad_external_id": "120210000000000123",
      "ad_name": "UGC — before/after — DM offer",
      "campaign_name": "Always-on — Messaging",
      "ad_set_name": "Prospecting — broad",
      "creative_thumbnail_url": "https://…",
      "video_id": "987654321",
      "score_window": "28d",
      "composite_score": 78,
      "top_performer_likelihood": { "status": "withheld", "value": null, "reason": "unmeasured" },
      "model_version": 3,
      "score_source": "model",
      "maturity_stage": "mature",
      "truth_grade": "attributed",
      "primary_outcome_kind": "messaging",
      "cohort_key": "meta|ugc_video|messaging|prospecting|geo_any|advantage",
      "cohort_objective": "messaging",
      "cohort_format": "ugc_video",
      "cohort_audience_warmth": "prospecting",
      "cohort_n_ads": 17,
      "hook_type": "before_after",
      "creative_format": "ugc_video",
      "cta_type": "send_dm",
      "primary_angle": "fast visible results",
      "offers": ["free consultation"],
      "spend": 4120.0,
      "impressions": 512000,
      "ctr": 1.42,
      "cpc": 0.57,
      "attributed_cash_outcomes": 6,
      "attributed_revenue": 9200.0,
      "roas": 2.23,
      "cac": 686.67,
      "attributed_outcomes_total": 18,
      "attributed_pipeline_outcomes": 12,
      "attributed_pipeline_value": 6400.0,
      "meta_reported_conversions": 0,
      "meta_reported_conversion_value": 0.0,
      "cost_per_conversation": 0.33,
      "messaging_conversations_started": 412,
      "first_reply_rate": 41.2,
      "avg_engagement_score": null,
      "reason_codes": [
        { "layer": "attention", "polarity": "strength", "percentile": 88, "sample_confidence": "high" },
        { "layer": "postclick_messaging", "polarity": "strength", "percentile": 91, "sample_confidence": "high" },
        { "layer": "attributed_revenue", "polarity": "strength", "percentile": 74, "sample_confidence": "medium" }
      ]
    }
  ],
  "meta": {
    "profile_id": "uuid"
  }
}
```

### Response fields

| Field                             | Type   | Description                                                                                          |
| --------------------------------- | ------ | ---------------------------------------------------------------------------------------------------- |
| `ad_external_id`                  | string | Platform ad id (e.g. Meta ad id).                                                                    |
| `composite_score`                 | number | 0–100, the cohort-normalized rule blend. `null` until the ad has been scored. **The headline rank.** |
| `top_performer_likelihood`        | object | `{ value, status, reason }` — a learned 0–1 probability, `served` only past the serving gate. **Not ROAS, not lift** — see the note above. |
| `model_version`                   | number | The ML ranker version that scored this ad, or `null` when `score_source` is `rules`.                  |
| `score_source`                    | string | `model` (the ML ranker produced the likelihood) or `rules` (it fell back to `composite_score`).       |
| `maturity_stage`                  | string | `cold` · `early` · `mature` · `calibrated` — how settled the score is.                                |
| `truth_grade`                     | string | `predicted` (proxy signals only) · `attributed` (≥1 Atribu-attributed outcome of any kind) · `lift` (experiment-validated). |
| `primary_outcome_kind`            | string | The headline outcome to show: `cash` · `pipeline` · `messaging` · `meta` (Meta's *own* attribution) · `none`. |
| `cohort_*`                        | string | The comparison cohort this ad was scored within, plus `cohort_n_ads` (cohort size).                  |
| `hook_type` / `creative_format` / `cta_type` / `primary_angle` / `offers` | mixed | Creative DNA — what to replicate.                                          |
| `attributed_revenue` / `roas` / `attributed_cash_outcomes` / `cac` | number | Atribu-attributed *cash* (`null`/`0` when none).                          |
| `attributed_outcomes_total` / `attributed_pipeline_outcomes` / `attributed_pipeline_value` | number | All Atribu-attributed conversions (any type); the pipeline subset (leads/appointments/closed-won) + its projected value (not cash). |
| `meta_reported_conversions` / `meta_reported_conversion_value` | number | Meta's *own* reported per-ad conversions (last-click — not Atribu's chain; never sets `truth_grade`). |
| `cost_per_conversation` / `messaging_conversations_started` / `first_reply_rate` | number | Messaging-objective signals (the "cheap conversations" lens).      |
| `reason_codes`                    | array  | `{ layer, polarity: strength\|weakness, percentile, sample_confidence: low\|medium\|high }` — why it ranks. |

<Callout type="info" title="Workspace-wide view">
  This endpoint is scoped to the API key's profile. The agency-wide
  cross-profile Top Performers (all clients in one ranked list, plus recurring
  creative patterns) is available through the Atribu **MCP server** —
  `top_workspace_performers`. See the [MCP docs](/docs/mcp/overview).
</Callout>

<Callout type="warn" title="Scores build on a daily cadence">
  Top-performer scores rebuild once a day (independent of the latency-sensitive
  attribution recompute). A freshly connected Meta account needs one sync cycle
  before its ads appear here. An empty `data` array usually means "not scored
  yet", not "no top performers".
</Callout>

## Platform vs Atribu

```http title="Endpoint"
GET /api/v1/campaigns/platform-comparison
```

What each ad platform **reports** it produced, next to what Atribu
**attributed** to the same campaign, ad set or ad under one model. The two are
never added together: platforms credit view-through and modeled conversions,
use their own windows and date a conversion to the ad interaction; Atribu
counts only conversions it can trace to a click, dates them when they happened,
and values them by cash collected.

**Scope:** `campaigns:read`

### Parameters

| Parameter   | Type   | Required | Description                                                  |
| ----------- | ------ | -------- | ------------------------------------------------------------ |
| `date_from` | string | Yes      | Start date, `YYYY-MM-DD`. Up to 366 days.                    |
| `date_to`   | string | Yes      | End date, `YYYY-MM-DD`, inclusive.                           |
| `model`     | string | No       | Attribution model for the Atribu side. Default `last_touch`. |
| `level`     | string | No       | `campaign`, `ad_set` or `ad`. Default `campaign`.            |

### Request

```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/campaigns/platform-comparison?date_from=2026-08-25&date_to=2026-09-23&level=campaign"
```

### Response fields

`data.platforms[]` holds one total per platform and `data.rows[]` one row per
entity at `level`. Both carry the same measures:

| Field | Type | Description |
| --- | --- | --- |
| `platform_conversions` | number \| null | The platform's own count. `null` when it reported nothing. |
| `platform_conversion_value` | string \| null | The platform's own value, in the reporting currency. |
| `atribu_conversions` / `atribu_cash_conversions` | integer | Conversions Atribu credited here, and the cash subset. |
| `atribu_cash_revenue` | string | Cash Atribu credited here (refunds subtracted). |
| `conversion_gap` / `conversion_gap_pct` | number \| null | Atribu minus platform. Negative: the platform claims more. |
| `value_gap` | string \| null | Atribu cash minus platform value, exact. |
| `platform_roas` / `atribu_roas` | number \| null | Each side's value over spend. |

Each platform total also says what its count means (`reported_basis`), whether
delivery was synced (`has_delivery`, `delivery_through`), and how much of
Atribu's credit came from paid clicks that named no ad (`unassigned_conversions`,
`unassigned_cash_revenue` — see `GET /api/v1/quality/ad-ids`).

---

## People reached by ads

```http title="Endpoint"
GET /api/v1/attribution/ad-people
```

How many **distinct people** had at least one ad-tagged Touch in each month,
day or ad of the window, where those Touches came from, and what Meta itself
reported for the same bucket (messaging conversations started), so the gap
between the two is visible.

The response is **counts only**: no person is named, listed or identified, and
no figure is a probability or a model's estimate. It is available for
healthcare businesses under `analytics:read`.

**Scope:** `analytics:read`

### Parameters

| Parameter    | Type   | Required | Description |
| ------------ | ------ | -------- | ----------- |
| `date_from`  | string | Yes      | Start date, `YYYY-MM-DD`, in the profile's time zone. Up to 366 days. |
| `date_to`    | string | Yes      | End date, `YYYY-MM-DD`, inclusive. |
| `group_by`   | string | No       | `month` (default), `day` or `ad`. |
| `channel`    | string | No       | `whatsapp`, `instagram`, `messenger`, `web` or `other`. |
| `first_only` | string | No       | `true` counts each person only in the bucket of their earliest-ever ad Touch. Default `false`. |
| `limit`      | string | No       | `group_by=ad` only: ads to return, 1 to 1000. Default 100. |

### Request

```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/attribution/ad-people?date_from=2026-02-01&date_to=2026-09-30&group_by=month"
```

### Response

```json
{
  "data": {
    "date_from": "2026-02-01",
    "date_to": "2026-09-30",
    "group_by": "month",
    "channel": null,
    "first_only": false,
    "timezone": "America/Santiago",
    "totals": {
      "ad_people": 3839,
      "ad_touches": 4465,
      "by_origin": {
        "partner_ad_referral": { "people": 3584, "touches": 4165 },
        "conversation_referral": { "people": 300, "touches": 300 },
        "web": { "people": 0, "touches": 0 },
        "other": { "people": 0, "touches": 0 }
      },
      "meta_messaging_conversations_started": 9704
    },
    "buckets": [
      {
        "key": "2026-09",
        "start": "2026-09-01",
        "end": "2026-09-30",
        "ad_id": null,
        "ad_name": null,
        "ad_people": 1050,
        "ad_touches": 1073,
        "by_origin": {
          "partner_ad_referral": { "people": 763, "touches": 773 },
          "conversation_referral": { "people": 300, "touches": 300 },
          "web": { "people": 0, "touches": 0 },
          "other": { "people": 0, "touches": 0 }
        },
        "meta_messaging_conversations_started": 1326
      }
    ],
    "buckets_total": 8,
    "buckets_truncated": false,
    "origin_labels": {
      "partner_ad_referral": {
        "label_en": "Imported from partner (historical)",
        "label_es": "Importado desde partner (historial)",
        "description": "..."
      }
    }
  },
  "meta": { "profile_id": "uuid", "date_from": "2026-02-01", "date_to": "2026-09-30" }
}
```

### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `ad_people` | integer | Distinct people with at least one ad Touch in the bucket. |
| `ad_touches` | integer | Ad Touches in the bucket. Equal to `ad_people` with `first_only=true`. |
| `by_origin.partner_ad_referral` | object | **Imported from a partner (historical):** ad referrals a partner handed over through `POST /api/v1/partner/ad-referrals` for conversations Atribu did not see live. |
| `by_origin.conversation_referral` | object | A WhatsApp or Instagram conversation Atribu received with Meta's ad referral. |
| `by_origin.web` | object | A tracked website visit whose link named the ad. |
| `by_origin.other` | object | Any other ad-tagged Touch (a CRM conversion's own ad parameters, a store journey, a Touch inherited by a recurring payment). |
| `meta_messaging_conversations_started` | integer \| null | **Meta's own** count for the bucket, by the ad account's day. Not narrowed by `channel`. `null` when there is no Meta delivery to report. |
| `totals` | object | The whole window with each person counted once. |
| `origin_labels` | object | English and Spanish labels for each `by_origin` key. |

Days and months are in the profile's time zone (`timezone`). A person reached
in two months counts once in each month and once in `totals`, so `totals` is
not the sum of the buckets, and a person reached through two origins counts in
both origins. Month and day buckets are dense (zeros included); ad buckets are
ranked by `ad_people` and cut at `limit` (`buckets_truncated`). The ad is read
from the Touch itself, never inferred from UTM text. Durable tables only, so any
range up to 366 days is supported.

`GET /api/v1/ads/trend` also carries Meta's `messaging_conversations_started_7d`
per ad and day.

---

## Related

<Cards>
  <Card title="Authentication" href="/docs/api/authentication">
    API key setup and scopes
  </Card>
  <Card title="Ads Performance" href="/docs/features/ads-performance">
    The dashboard view that uses the same data
  </Card>
  <Card title="Attribution Models" href="/docs/concepts/attribution-models">
    How the model parameter affects ROAS rankings
  </Card>
</Cards>

---

# Creative analysis

Atribu describes what an ad's creative actually *is* — its angle, offer, hook and
claim style, and for video a verbatim transcript and a beat-by-beat scene
timeline. A background pass produces these; two endpoints expose them.

The two are deliberately different in kind. `GET` is a read of something already
stored and costs nothing. `POST` asks for the analysis to be produced, which
means a multimodal model call on the ad's video — so it is budgeted, it needs its
own scope, and it never runs on the request itself.

## Get an ad's creative analysis

```http title="Endpoint"
GET /api/v1/ads/{id}/creative-analysis
```

`{id}` is the ad's **platform (external) id** — the provider's own id, the same
value `GET /api/v1/top-performers` returns as `ad_external_id` and every
`GET /api/v1/campaigns?level=ad` row carries as `platform_id`. Atribu's internal
uuid is never a public identifier.

**Scope:** `campaigns:read`

This issues no model call and counts against no AI allowance. Two consequences follow:

- **Coverage is not guaranteed.** An ad the pass has not reached answers `404`,
  with a message distinct from the one a wrong or foreign ad id gets.
- **Freshness is the pass's, not the request's.** Read `analyzed_at`.

`method` tells you which tier produced the row and therefore how much of `video`
is populated: `llm_vision_v2` (the video was analysed), `llm_thumbnail_v2` (still
image only), `llm_copy_only_v2` (no visual). On the lower two tiers every
time-based field is `null` and `scenes` is empty — a `null` there means *not
observed*, never *the ad does not do this*.

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/ads/120210000000000001/creative-analysis"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
const res = await fetch(
  "https://api.atribu.app/api/v1/ads/120210000000000001/creative-analysis",
  { headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
const { data } = await res.json();
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

res = requests.get(
    "https://api.atribu.app/api/v1/ads/120210000000000001/creative-analysis",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
)
data = res.json()["data"]
```
</Tab>
</Tabs>

### Response

```json title="Success response (200 OK)"
{
  "data": {
    "ad_external_id": "120210000000000001",
    "ad_name": "Test drive — 60s",
    "analysis_version": 2,
    "method": "llm_vision_v2",
    "model_id": "gemini-2.5-flash",
    "confidence": "high",
    "video_source_resolved": true,
    "analyzed_at": "2026-08-20T11:50:00.000Z",
    "creative": {
      "primary_angle": "Book a test drive in 60 seconds",
      "offers": ["free test drive"],
      "cta_type": "book_appointment",
      "hook_type": "question",
      "claim_style": "specific_number",
      "language_detected": "en"
    },
    "video": {
      "transcript": "It would take you 60 seconds to book your test drive.",
      "n_scenes": 2,
      "narrative_arc_pattern": "hook>cta",
      "scenes": [
        { "start_s": 0, "end_s": 2.4, "role": "hook", "on_screen_text": "60 SECONDS" }
      ]
    }
  },
  "meta": { "profile_id": "..." }
}
```

Embeddings and the derived per-role duration scalars are not published — they are
ML ranker inputs, and `video.scenes` carries the same observations in the form
they were made.

## Queue an on-demand analysis

```http title="Endpoint"
POST /api/v1/ads/{id}/creative-analysis
```

Ask for an ad's creative to be analysed now instead of waiting for the daily
pass. **This queues a model call; it does not make one.** The response is a
receipt, and the analysis itself appears at the `GET` above — which is what the
`Location` header points at.

**Scope:** `creatives:write`

There is **no request body**. The ad is in the path and the caller is in the
credential.

### It counts against the workspace's daily creative-analysis allowance, so it is budgeted

Two ceilings apply, both per **UTC day**, both configurable per tenant:

| Ceiling | Default |
|---------|---------|
| Per profile | 20 analyses per UTC day |
| Per API key | 100 analyses per UTC day |

A key belongs to exactly one profile, so at the defaults the profile ceiling is
the one you will meet. Exceeding either answers `429` with `Retry-After` set to
the seconds remaining until the next UTC midnight; the message names which
ceiling fired, the counters, and the reset instant. Every response carries
`data.budget` so you can pace yourself rather than discovering the limit as an
error.

This is separate from the ordinary per-minute rate limit, and the endpoint also
carries its own hourly burst cap (see
[Rate limits](/docs/api/authentication#rate-limits)).

### It is idempotent for 24 hours

The idempotency key is the ad **plus its creative** — the ad's name, headline,
body, video and thumbnail. Inside 24 hours, with none of those changed:

- `202 Accepted` — a new job was admitted. `reused: false`.
- `200 OK` — the existing job came back. `reused: true`, the same `id`, and
  **no budget consumed**.

Edit the creative and the next `POST` is a new job, which is what you want: the
old analysis describes an ad that no longer exists.

### How a job ends, and how you find out

`status` on the returned job is always live — `queued`, `running` or
`succeeded`. A run that ended badly is never handed back as the current job;
instead the next `POST` admits a fresh job and reports the last one in
`previous_attempt`:

| `previous_attempt.status` | Meaning |
|---------------------------|---------|
| `failed` | The run was attempted and produced no analysis. It consumed a slot of your daily budget. |
| `blocked` | The run was **never attempted** — the background analysis pass is switched off for this deployment. It consumed nothing. |

That is also the answer for a profile where the pass is off: you get a job, it
terminates `blocked`, and it costs you nothing.

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -X POST -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/ads/120210000000000001/creative-analysis"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
const res = await fetch(
  "https://api.atribu.app/api/v1/ads/120210000000000001/creative-analysis",
  { method: "POST", headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
// 202 = queued now, 200 = an existing job for this same creative.
const { data } = await res.json();
// Poll the GET on the same path (res.headers.get("Location")).
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

res = requests.post(
    "https://api.atribu.app/api/v1/ads/120210000000000001/creative-analysis",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
)
job = res.json()["data"]
```
</Tab>
</Tabs>

### Response

```json title="Accepted (202)"
{
  "data": {
    "id": "8f2b1c44-0a3d-4d1e-9c77-2f5b6a0c1d33",
    "ad_external_id": "120210000000000001",
    "status": "queued",
    "reused": false,
    "requested_at": "2026-09-02T12:00:00.000Z",
    "analysis_version": 2,
    "input_hash": "5942f10844840359b7c684eda0be84cf",
    "previous_attempt": null,
    "budget": {
      "profile_used": 1,
      "profile_cap": 20,
      "api_key_used": 1,
      "api_key_cap": 100
    }
  },
  "meta": { "profile_id": "..." }
}
```

```json title="Budget exhausted (429)"
{
  "error": {
    "code": "rate_limit_exceeded",
    "message": "This profile has used 20 of 20 on-demand creative analyses today (the budget is per UTC day). The stored analysis is still readable with GET on the same path. Resets at 2026-09-03T00:00:00.000Z.",
    "status": 429,
    "request_id": "..."
  }
}
```

## Related

<Cards>
  <Card title="Campaigns" href="/docs/api/campaigns" description="Campaign, ad set and ad performance." />
  <Card title="Authentication" href="/docs/api/authentication" description="Scopes, rate limits and key rotation." />
</Cards>

---

# Conversions & Revenue

Four endpoints for querying conversion activity and revenue attribution. All
require the `conversions:read` scope.

<Callout type="warn" title="Revenue means cash only">
  Revenue in all endpoints below refers exclusively to confirmed payments from
  Stripe and MercadoPago. GHL pipeline values (deal stages, closed-won
  projections) are **never** included in revenue or ROAS calculations -- they
  are projections, not confirmed cash.
</Callout>

---

## Conversion Counts

```http title="Endpoint"
GET /api/v1/conversions
```

Returns total conversions grouped by event type for the requested period.

**Scope:** `conversions:read`

### Parameters

| Parameter   | Type   | Required | Description                        |
| ----------- | ------ | -------- | ---------------------------------- |
| `date_from` | string | Yes      | Start date in `YYYY-MM-DD` format. |
| `date_to`   | string | Yes      | End date in `YYYY-MM-DD` format.   |

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/conversions?date_from=2026-03-01&date_to=2026-03-25"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
const res = await fetch(
  "https://www.atribu.app/api/v1/conversions?date_from=2026-03-01&date_to=2026-03-25",
  { headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
const { data } = await res.json();
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

res = requests.get(
    "https://www.atribu.app/api/v1/conversions",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={"date_from": "2026-03-01", "date_to": "2026-03-25"},
)
data = res.json()["data"]
```
</Tab>
</Tabs>

### Response

```json title="Success response (200 OK)"
{
  "data": [
    { "event_type": "lead_created", "count": 85 },
    { "event_type": "appointment_booked", "count": 42 },
    { "event_type": "payment_received", "count": 28 },
    { "event_type": "closed_won", "count": 15 }
  ],
  "meta": {
    "date_from": "2026-03-01",
    "date_to": "2026-03-25",
    "profile_id": "uuid"
  }
}
```

### Response fields

| Field        | Type   | Description                                      |
| ------------ | ------ | ------------------------------------------------ |
| `event_type` | string | Conversion type (e.g., `lead_created`, `payment_received`). |
| `count`      | number | Total conversions of this type in the period.    |

---

## Daily Conversions

```http title="Endpoint"
GET /api/v1/conversions/timeseries
```

Returns daily conversion counts broken down by event type. Use this to chart
conversion trends over time.

**Scope:** `conversions:read`

### Parameters

| Parameter   | Type   | Required | Description                        |
| ----------- | ------ | -------- | ---------------------------------- |
| `date_from` | string | Yes      | Start date in `YYYY-MM-DD` format. |
| `date_to`   | string | Yes      | End date in `YYYY-MM-DD` format.   |

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/conversions/timeseries?date_from=2026-03-20&date_to=2026-03-25"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
const res = await fetch(
  "https://www.atribu.app/api/v1/conversions/timeseries?date_from=2026-03-20&date_to=2026-03-25",
  { headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
const { data } = await res.json();
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

res = requests.get(
    "https://www.atribu.app/api/v1/conversions/timeseries",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={"date_from": "2026-03-20", "date_to": "2026-03-25"},
)
data = res.json()["data"]
```
</Tab>
</Tabs>

### Response

```json title="Success response (200 OK)"
{
  "data": [
    { "date": "2026-03-20", "event_type": "lead_created", "count": 12 },
    { "date": "2026-03-20", "event_type": "payment_received", "count": 3 },
    { "date": "2026-03-21", "event_type": "lead_created", "count": 15 },
    { "date": "2026-03-21", "event_type": "appointment_booked", "count": 7 }
  ],
  "meta": {
    "date_from": "2026-03-20",
    "date_to": "2026-03-25",
    "profile_id": "uuid"
  }
}
```

### Response fields

| Field        | Type   | Description                                    |
| ------------ | ------ | ---------------------------------------------- |
| `date`       | string | Calendar date (`YYYY-MM-DD`).                  |
| `event_type` | string | Conversion type.                               |
| `count`      | number | Number of conversions of this type on this day.|

---

## Revenue by Day

```http title="Endpoint"
GET /api/v1/revenue
```

Returns daily attributed revenue and ad spend. Revenue is computed using the
selected attribution model and includes cash payments only.

**Scope:** `conversions:read`

### Parameters

| Parameter   | Type   | Required | Description                                                                 |
| ----------- | ------ | -------- | --------------------------------------------------------------------------- |
| `date_from` | string | Yes      | Start date in `YYYY-MM-DD` format.                                          |
| `date_to`   | string | Yes      | End date in `YYYY-MM-DD` format.                                            |
| `model`     | string | No       | Attribution model. Default `last_touch`. Options: `first_touch`, `linear`, `time_decay`, `last_non_direct`. |

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/revenue?date_from=2026-03-20&date_to=2026-03-25&model=last_touch"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
const res = await fetch(
  "https://www.atribu.app/api/v1/revenue?date_from=2026-03-20&date_to=2026-03-25&model=last_touch",
  { headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
const { data } = await res.json();
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

res = requests.get(
    "https://www.atribu.app/api/v1/revenue",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={
        "date_from": "2026-03-20",
        "date_to": "2026-03-25",
        "model": "last_touch",
    },
)
data = res.json()["data"]
```
</Tab>
</Tabs>

### Response

```json title="Success response (200 OK)"
{
  "data": [
    { "date": "2026-03-20", "revenue": 2400.00, "spend": 680.00 },
    { "date": "2026-03-21", "revenue": 1800.00, "spend": 720.00 },
    { "date": "2026-03-22", "revenue": 3200.00, "spend": 650.00 }
  ],
  "meta": {
    "date_from": "2026-03-20",
    "date_to": "2026-03-25",
    "profile_id": "uuid"
  }
}
```

### Response fields

| Field     | Type   | Description                                      |
| --------- | ------ | ------------------------------------------------ |
| `date`    | string | Calendar date (`YYYY-MM-DD`).                    |
| `revenue` | number | Attributed cash revenue in reporting currency.   |
| `spend`   | number | Total ad spend across all platforms.              |

---

## Cash Collected

```http title="Endpoint"
GET /api/v1/revenue/cash
```

Returns total cash payments grouped by payment source and currency. Unlike the
attributed revenue endpoint above, this shows raw payment totals regardless of
attribution model.

**Scope:** `conversions:read`

### Parameters

| Parameter   | Type   | Required | Description                        |
| ----------- | ------ | -------- | ---------------------------------- |
| `date_from` | string | Yes      | Start date in `YYYY-MM-DD` format. |
| `date_to`   | string | Yes      | End date in `YYYY-MM-DD` format.   |

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/revenue/cash?date_from=2026-03-01&date_to=2026-03-25"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
const res = await fetch(
  "https://www.atribu.app/api/v1/revenue/cash?date_from=2026-03-01&date_to=2026-03-25",
  { headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
const { data } = await res.json();
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

res = requests.get(
    "https://www.atribu.app/api/v1/revenue/cash",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={"date_from": "2026-03-01", "date_to": "2026-03-25"},
)
data = res.json()["data"]
```
</Tab>
</Tabs>

### Response

```json title="Success response (200 OK)"
{
  "data": [
    { "source": "stripe", "currency": "USD", "total_cash": "9800.00", "count": 35 },
    { "source": "mercadopago", "currency": "MXN", "total_cash": "45000.00", "count": 12 }
  ],
  "meta": {
    "date_from": "2026-03-01",
    "date_to": "2026-03-25",
    "profile_id": "uuid"
  }
}
```

### Response fields

| Field        | Type   | Description                                                             |
| ------------ | ------ | ------------------------------------------------------------------------ |
| `source`     | string | Payment provider (`stripe`, `mercadopago`).                              |
| `currency`   | string | ISO 4217 currency code.                                                  |
| `total_cash` | string | Exact decimal string. Sum of payments in the original currency (ADR 0018 — parse before doing arithmetic). Renamed from `total`. |
| `count`      | number | Number of payments.                                                     |

<Callout type="info" title="Cash collected vs. attributed revenue">
  **Cash collected** (`/revenue/cash`) is the raw sum of payments, independent
  of any attribution model. **Attributed revenue** (`/revenue`) distributes
  payment value across the marketing touchpoints that contributed to the
  conversion, based on the selected model. Use cash collected for financial
  reconciliation and attributed revenue for marketing performance analysis.
</Callout>

---

## Winning Campaign per Conversion

```http title="Endpoint"
GET /api/v1/conversions/attribution
```

Every conversion of the window with the campaign that wins its credit, in one
keyset-paginated walk. It replaces reading
`GET /api/v1/campaigns/{id}/conversions` once per campaign and folding the rows:
per campaign the credit is grouped exactly as that route groups it, and the
winner is the campaign with the highest `credited_weight`; on a tie a direct
credit beats an inherited one, then the most recent `last_touch_time`, then the
lowest `campaign_external_id`. Only conversions credited to at least one
campaign appear.

**Scope:** `analytics:read` or `campaigns:read` (either one). Unlike the other
endpoints on this page it does not need `conversions:read`.

### Parameters

| Parameter   | Type    | Required | Description                                                              |
| ----------- | ------- | -------- | ------------------------------------------------------------------------ |
| `date_from` | string  | Yes      | First day, `YYYY-MM-DD`, in the profile's timezone.                      |
| `date_to`   | string  | Yes      | Last day, inclusive. At most 366 days after `date_from`.                 |
| `model`     | string  | No       | Attribution model. Defaults to `last_touch`.                             |
| `limit`     | integer | No       | Conversions per page, 1–1000. Defaults to 500. Anything else is a `400`. |
| `cursor`    | string  | No       | `pagination.cursor` from the previous page, unchanged.                   |

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/conversions/attribution?date_from=2026-01-01&date_to=2026-10-15&limit=1000"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
// Walk every page.
const rows = [];
let cursor;
do {
  const params = new URLSearchParams({ date_from: "2026-01-01", date_to: "2026-10-15", limit: "1000" });
  if (cursor) params.set("cursor", cursor);
  const res = await fetch(`https://api.atribu.app/api/v1/conversions/attribution?${params}`, {
    headers: { Authorization: "Bearer atb_live_YOUR_KEY" },
  });
  const page = await res.json();
  rows.push(...page.data);
  cursor = page.pagination.has_next ? page.pagination.cursor : undefined;
} while (cursor);
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

rows, cursor = [], None
while True:
    params = {"date_from": "2026-01-01", "date_to": "2026-10-15", "limit": 1000}
    if cursor:
        params["cursor"] = cursor
    page = requests.get(
        "https://api.atribu.app/api/v1/conversions/attribution",
        headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
        params=params,
    ).json()
    rows += page["data"]
    if not page["pagination"]["has_next"]:
        break
    cursor = page["pagination"]["cursor"]
```
</Tab>
</Tabs>

### Response

```json title="200 OK"
{
  "data": [
    {
      "conversion_id": "00000000-0000-4000-8000-000000000001",
      "outcome_event_id": "00000000-0000-4000-8000-000000000002",
      "campaign_external_id": "120200000000000001",
      "campaign_name": "Implants — Leads",
      "credited_weight": 1,
      "credited_value": "50000",
      "touch_count": 3,
      "campaign_touch_count": 1,
      "path_length": 3,
      "event_time": "2026-09-30T14:03:11.123+00:00",
      "first_touch_time": "2026-09-21T22:10:05+00:00",
      "last_touch_time": "2026-09-21T22:10:05+00:00",
      "is_inherited": false,
      "event_type": "payment_received",
      "revenue_type": "cash",
      "value_amount": "50000",
      "currency": "CLP",
      "customer_key": "ghl_contact_0001",
      "is_first_payment": true
    }
  ],
  "pagination": { "has_next": true, "cursor": "eyJrIjoiY2EiLCJ0Ijo..." },
  "meta": {
    "model": "last_touch",
    "recomputed_at": "2026-10-15T12:00:00+00:00",
    "date_from": "2026-01-01",
    "date_to": "2026-10-15",
    "profile_id": "00000000-0000-4000-8000-0000000000aa"
  }
}
```

### Response fields

| Field                  | Type            | Description                                                                                          |
| ---------------------- | --------------- | ---------------------------------------------------------------------------------------------------- |
| `conversion_id`        | string          | Atribu's conversion id.                                                                              |
| `outcome_event_id`     | string \| null  | The outcome event id returned at ingest. `outcome_event_id ?? conversion_id` is a stable key.        |
| `campaign_external_id` | string          | The winning campaign's platform id.                                                                  |
| `campaign_name`        | string          | The campaign's current name.                                                                         |
| `credited_weight`      | number          | The winning campaign's share of the conversion, 0–1.                                                 |
| `credited_value`       | string \| null  | Its credited share of the value. Exact decimal string.                                               |
| `touch_count`          | integer         | Touches in the whole path (`path_length`), else `campaign_touch_count`.                              |
| `campaign_touch_count` | integer         | Credited touches on the winning campaign.                                                            |
| `path_length`          | integer \| null | Touches in the whole path.                                                                           |
| `event_time`           | string          | When the conversion happened (ISO 8601).                                                             |
| `first_touch_time`     | string \| null  | Earliest credited touch on the winning campaign.                                                     |
| `last_touch_time`      | string \| null  | Latest credited touch on the winning campaign.                                                       |
| `is_inherited`         | boolean         | Credit came via a bridged lead→cash touch — never counted in ROAS.                                   |
| `event_type`           | string          | `lead_created`, `appointment_booked`, `payment_received`, `closed_won`, …                            |
| `revenue_type`         | string          | `cash`, `pipeline`, …                                                                                |
| `value_amount`         | string \| null  | The conversion's full value. Exact decimal string.                                                   |
| `currency`             | string \| null  | Reporting currency (ISO 4217).                                                                       |
| `customer_key`         | string \| null  | CRM contact id (not a name or email).                                                                |
| `is_first_payment`     | boolean \| null | The customer's first payment. `null` unless `revenue_type` is `cash`.                                |

Newest first. While `pagination.has_next` is true, pass `pagination.cursor`
back as `cursor` with the same other parameters; a cursor this endpoint did not
issue is a `400 invalid_parameter`. There is no `updated_since`: an attribution
recompute re-credits conversions without changing them, so re-walk the window
(`meta.recomputed_at` says when the profile's attribution last changed).

## Related

<Cards>
  <Card title="Authentication" href="/docs/api/authentication">
    API key setup and scopes
  </Card>
  <Card title="Revenue Types" href="/docs/concepts/revenue-types">
    Why only cash payments count for revenue and ROAS
  </Card>
  <Card title="Conversion Goals" href="/docs/settings/goals">
    Configure which events count as conversions
  </Card>
</Cards>

---

# Customers & Journey

<Callout type="error" title="PII scope required">
  The read endpoints on this page return personally identifiable information
  (names, emails). Your API key must have the `customers:read` scope explicitly
  granted. Keys without this scope will receive a `403 Forbidden` response. The
  Import endpoint is a write and uses a separate scope, `attribution:write` —
  see its section below.
</Callout>

---

## Customer List

```http title="Endpoint"
GET /api/v1/customers
```

Returns a paginated list of customers who achieved a specific conversion goal
within the date range. Each row includes the customer's identity, the conversion
details, channel attribution, and lifetime metrics.

**Scope:** `customers:read`

### Parameters

| Parameter     | Type   | Required | Description                                                        |
| ------------- | ------ | -------- | ------------------------------------------------------------------ |
| `date_from`   | string | Yes      | Start date in `YYYY-MM-DD` format.                                 |
| `date_to`     | string | Yes      | End date in `YYYY-MM-DD` format.                                   |
| `goal`        | string | Yes      | Conversion type: `payment_received`, `lead_created`, `appointment_booked`, `closed_won`, etc. |
| `search`      | string | No       | Search by customer name or email.                                  |
| `limit`       | number | No       | Results per page. Default `10`, max `100`.                         |
| `cursor_time` | string | No       | Pagination cursor timestamp (from previous response).              |
| `cursor_id`   | string | No       | Pagination cursor ID (from previous response).                     |

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/customers?date_from=2026-03-01&date_to=2026-03-25&goal=payment_received&limit=5"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
const res = await fetch(
  "https://www.atribu.app/api/v1/customers?date_from=2026-03-01&date_to=2026-03-25&goal=payment_received&limit=5",
  { headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
const { data, pagination } = await res.json();
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

res = requests.get(
    "https://www.atribu.app/api/v1/customers",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={
        "date_from": "2026-03-01",
        "date_to": "2026-03-25",
        "goal": "payment_received",
        "limit": 5,
    },
)
body = res.json()
data = body["data"]
has_next = body["pagination"]["has_next"]
```
</Tab>
</Tabs>

### Response

```json title="Success response (200 OK)"
{
  "data": [
    {
      "conversion_id": "uuid",
      "customer_profile_id": "uuid",
      "name": "Jane Smith",
      "email": "jane@example.com",
      "country": "US",
      "device": "mobile",
      "channel": "Paid Social",
      "source": "ig",
      "revenue": 299.00,
      "revenue_type": "cash",
      "conversion_time": "2026-03-22T14:30:00Z",
      "time_to_complete_seconds": 172800,
      "touch_count": 3,
      "touch_channels": ["Paid Social", "Direct"],
      "conversion_count": 2,
      "total_revenue": 598.00
    }
  ],
  "pagination": {
    "has_next": true,
    "cursor": "2026-03-22T14:30:00Z|uuid"
  },
  "meta": {
    "date_from": "2026-03-01",
    "date_to": "2026-03-25",
    "profile_id": "uuid"
  }
}
```

### Response fields

| Field                      | Type     | Description                                                     |
| -------------------------- | -------- | --------------------------------------------------------------- |
| `conversion_id`            | string   | Unique ID of this conversion event.                             |
| `customer_profile_id`      | string   | Customer's profile UUID. Use this for the Journey endpoint.     |
| `name`                     | string   | Customer's full name (may be `null` for anonymous visitors).    |
| `email`                    | string   | Customer's email address (may be `null`).                       |
| `country`                  | string   | ISO country code from the converting session.                   |
| `device`                   | string   | Device type: `desktop`, `mobile`, `tablet`.                     |
| `channel`                  | string   | Marketing channel of the last touch before conversion.          |
| `source`                   | string   | Traffic source (e.g., `ig`, `fb`, `google`).                    |
| `revenue`                  | number   | Revenue attributed to this specific conversion.                 |
| `revenue_type`             | string   | `cash`, `pipeline`, or `gross`.                                 |
| `conversion_time`          | string   | ISO 8601 timestamp of the conversion.                           |
| `time_to_complete_seconds` | number   | Seconds between first touch and conversion.                     |
| `touch_count`              | number   | Number of marketing touchpoints before conversion.              |
| `touch_channels`           | string[] | Distinct channels across all touchpoints.                       |
| `conversion_count`         | number   | Total conversions by this customer (lifetime).                  |
| `total_revenue`            | number   | Total revenue from this customer (lifetime).                    |

### Pagination

This endpoint uses cursor-based pagination. The `pagination.cursor` field in the
response contains the values needed to fetch the next page.

<Steps>
<Step>
Fetch the first page with your desired `limit`.
</Step>
<Step>
Check `pagination.has_next`. If `true`, split `pagination.cursor` by `|` to get
`cursor_time` and `cursor_id`.
</Step>
<Step>
Pass both values in the next request.
</Step>
</Steps>

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL — fetch page 2"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/customers?date_from=2026-03-01&date_to=2026-03-25&goal=payment_received&cursor_time=2026-03-22T14:30:00Z&cursor_id=uuid"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript — paginate through all results"
let allCustomers = [];
let cursorTime = undefined;
let cursorId = undefined;

while (true) {
  const url = new URL("https://www.atribu.app/api/v1/customers");
  url.searchParams.set("date_from", "2026-03-01");
  url.searchParams.set("date_to", "2026-03-25");
  url.searchParams.set("goal", "payment_received");
  url.searchParams.set("limit", "50");
  if (cursorTime) url.searchParams.set("cursor_time", cursorTime);
  if (cursorId) url.searchParams.set("cursor_id", cursorId);

  const res = await fetch(url, {
    headers: { Authorization: "Bearer atb_live_YOUR_KEY" },
  });
  const body = await res.json();
  allCustomers.push(...body.data);

  if (!body.pagination.has_next) break;
  [cursorTime, cursorId] = body.pagination.cursor.split("|");
}
```
</Tab>
<Tab value="Python">
```python title="Python — paginate through all results"
import requests

all_customers = []
cursor_time = None
cursor_id = None

while True:
    params = {
        "date_from": "2026-03-01",
        "date_to": "2026-03-25",
        "goal": "payment_received",
        "limit": 50,
    }
    if cursor_time:
        params["cursor_time"] = cursor_time
        params["cursor_id"] = cursor_id

    body = requests.get(
        "https://www.atribu.app/api/v1/customers",
        headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
        params=params,
    ).json()

    all_customers.extend(body["data"])

    if not body["pagination"]["has_next"]:
        break
    cursor_time, cursor_id = body["pagination"]["cursor"].split("|")
```
</Tab>
</Tabs>

---

## Import Customers

```http title="Endpoint"
POST /api/v1/customers
```

Batch-upserts customer identities directly into the identity graph — a
practice-management export (Dentalink, Medilink, a spreadsheet) or a CSV, not
an event. No `outcome_events` row is written and no event name is picked; this
is the write sibling of the Customer List endpoint above.

**Scope:** `attribution:write`

<Callout type="warn" title="Every row needs at least one strong identifier">
  A row must carry `phone`, `email`, or `national_id`. A row with only
  `external_id` is **rejected**, not silently dropped and not used to create a
  profile — a profile minted from a foreign key alone can never be merged with
  the real person later.
</Callout>

### Request body

| Field     | Type   | Required | Description                                                                 |
| --------- | ------ | -------- | ---------------------------------------------------------------------------- |
| `country` | string | No       | ISO 3166-1 alpha-2 hint for the WHOLE batch's `national_id` parsing. Defaults to `CL` — the only country supported today. |
| `rows`    | array  | Yes      | Up to 1,000 row objects (see below).                                        |

Each entry in `rows`:

| Field         | Type   | Required            | Description                                                                 |
| ------------- | ------ | -------------------- | ---------------------------------------------------------------------------- |
| `phone`       | string | See above            | Normalised to E.164 when parseable.                                        |
| `email`       | string | See above            | Normalised (lowercased, trimmed).                                          |
| `national_id` | string | See above            | A government identity document number (e.g. a Chilean RUT).                |
| `external_id` | string | No                   | YOUR customer id (PMS/CRM primary key). Never enough alone.                |
| `first_name`  | string | No                   | Only filled in on a profile that doesn't already have one.                 |
| `last_name`   | string | No                   | Same rule as `first_name`.                                                 |

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -X POST "https://www.atribu.app/api/v1/customers" \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "country": "CL",
    "rows": [
      { "phone": "+56911112222", "national_id": "14.146.609-7", "first_name": "María", "last_name": "Pérez" },
      { "email": "buyer@example.com", "national_id": "20473794-0" }
    ]
  }'
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
const res = await fetch("https://www.atribu.app/api/v1/customers", {
  method: "POST",
  headers: {
    Authorization: "Bearer atb_live_YOUR_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    country: "CL",
    rows: [
      { phone: "+56911112222", national_id: "14.146.609-7", first_name: "María" },
      { email: "buyer@example.com", national_id: "20473794-0" },
    ],
  }),
});
const { data } = await res.json();
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

res = requests.post(
    "https://www.atribu.app/api/v1/customers",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    json={
        "country": "CL",
        "rows": [
            {"phone": "+56911112222", "national_id": "14.146.609-7", "first_name": "María"},
            {"email": "buyer@example.com", "national_id": "20473794-0"},
        ],
    },
)
data = res.json()["data"]
```
</Tab>
</Tabs>

### Response

```json title="Success response (200 OK)"
{
  "data": {
    "results": [
      { "index": 0, "status": "created", "customer_profile_id": "uuid" },
      { "index": 1, "status": "matched", "customer_profile_id": "uuid" }
    ],
    "summary": { "total": 2, "created": 1, "matched": 1, "rejected": 0 }
  },
  "meta": { "profile_id": "uuid" }
}
```

### Response fields

| Field                          | Type   | Description                                                        |
| ------------------------------ | ------ | -------------------------------------------------------------------- |
| `results[].index`               | number | The row's position in the request's `rows` array.                  |
| `results[].status`              | string | `created`, `matched` (resolved to an existing profile), or `rejected`. |
| `results[].customer_profile_id` | string | The identity the row resolved to, or `null` when `rejected`.       |
| `results[].reason`              | string | Present only on a `rejected` row.                                  |
| `summary`                        | object | Row-status counts for the whole batch.                             |

<Callout type="info" title="Degrades visibly, never drops">
  Every row gets a result, in request order. A malformed or under-identified
  row never aborts the batch and never disappears silently — check each row's
  own `status`.
</Callout>

---

## Customer Journey

```http title="Endpoint"
GET /api/v1/customers/{id}/journey
```

Returns the full event timeline for a single customer -- every page view, form
submission, booking, payment, and marketing touchpoint in chronological order.

**Scope:** `customers:read`

### Parameters

| Parameter | Type   | Required | Description                                   |
| --------- | ------ | -------- | --------------------------------------------- |
| `id`      | string | Yes      | Customer profile UUID (path parameter).       |
| `offset`  | number | No       | Skip the first N events. Default `0`.         |
| `limit`   | number | No       | Maximum events to return. Default `50`, max `100`. |

<Callout type="info" title="BOLA protection">
  This endpoint enforces object-level authorization. If the customer ID does not
  belong to your profile, it returns empty data (not an error). This prevents
  information disclosure about whether specific customer IDs exist in the system.
</Callout>

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="cURL"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/customers/customer-uuid/journey?limit=20"
```
</Tab>
<Tab value="JavaScript">
```js title="JavaScript"
const customerId = "customer-uuid";
const res = await fetch(
  `https://www.atribu.app/api/v1/customers/${customerId}/journey?limit=20`,
  { headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
const { data } = await res.json();
console.log(`${data.total_count} events in journey`);
```
</Tab>
<Tab value="Python">
```python title="Python"
import requests

customer_id = "customer-uuid"
res = requests.get(
    f"https://www.atribu.app/api/v1/customers/{customer_id}/journey",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={"limit": 20},
)
events = res.json()["data"]["events"]
```
</Tab>
</Tabs>

### Response

```json title="Success response (200 OK)"
{
  "data": {
    "events": [
      {
        "event_type": "page_view",
        "event_name": "page_view",
        "event_time": "2026-03-18T10:15:00Z",
        "url": "https://example.com/pricing",
        "path": "/pricing",
        "channel": "Paid Social",
        "source": "ig",
        "medium": "paid",
        "campaign": "Spring Sale",
        "value_amount": null,
        "currency": null,
        "device": "mobile",
        "browser": "Safari",
        "os": "iOS",
        "country": "US",
        "city": "New York",
        "is_synthetic": false
      },
      {
        "event_type": "payment_received",
        "event_name": "payment_received",
        "event_time": "2026-03-20T16:45:00Z",
        "url": null,
        "path": null,
        "channel": null,
        "source": "stripe",
        "medium": null,
        "campaign": null,
        "value_amount": 299.00,
        "currency": "USD",
        "device": null,
        "browser": null,
        "os": null,
        "country": null,
        "city": null,
        "is_synthetic": false
      }
    ],
    "total_count": 12
  },
  "meta": {
    "profile_id": "uuid"
  }
}
```

### Response fields

| Field          | Type    | Description                                                   |
| -------------- | ------- | ------------------------------------------------------------- |
| `event_type`   | string  | Event category (`page_view`, `lead_created`, `payment_received`, etc.). |
| `event_name`   | string  | Specific event name.                                          |
| `event_time`   | string  | ISO 8601 timestamp.                                           |
| `url`          | string  | Full page URL (web events only).                              |
| `path`         | string  | URL path component.                                           |
| `channel`      | string  | Classified marketing channel.                                 |
| `source`       | string  | Traffic source or payment provider.                           |
| `medium`       | string  | Marketing medium (`paid`, `organic`, `referral`, etc.).       |
| `campaign`     | string  | Campaign name (resolved from platform ID).                    |
| `value_amount` | number  | Monetary value (for payment and deal events).                 |
| `currency`     | string  | ISO 4217 currency code.                                       |
| `device`       | string  | Device type.                                                  |
| `browser`      | string  | Browser name.                                                 |
| `os`           | string  | Operating system.                                             |
| `country`      | string  | ISO country code.                                             |
| `city`         | string  | City name.                                                    |
| `is_synthetic` | boolean | `true` for off-site conversions that had no website visit.    |
| `total_count`  | number  | Total events in the journey (use with `offset`/`limit` for paging). |

<Callout type="info" title="Synthetic touchpoints">
  Events with `is_synthetic: true` represent off-site conversions (e.g., Meta
  lead forms submitted via Instagram that flow into GoHighLevel without a
  website visit). Atribu creates synthetic touchpoints for these so they can
  still be attributed to the originating ad campaign. See [Synthetic Touches](/docs/concepts/synthetic-touches) for details.
</Callout>

## Journey by Your Customer Key

```http title="Endpoints"
GET /api/v1/customers/journey?customer_key={your_customer_id}
GET /api/v1/conversions/{id}/journey
```

The first returns one person's whole timeline, named by the customer id you
send as `user_traits.external_id` on every event, so you never store Atribu's
customer id. The second returns the timeline behind one conversion, named by
its `outcome_event_id`, a `conversions.id`, or your own event key.

**Scope:** `conversions:read` or `customers:read`. The scope decides the shape:
with `customers:read`, the rows above plus the person's name and identifiers in
`meta.person`; without it, a projection with the touches, channels, campaigns,
ad sets, ads and credit, and no person, page, device, location or session
fields. `?email=` / `?phone=` instead of `customer_key` need `customers:read`.
The projected shape is documented in [Partner apps](/docs/api/partners#journey-reads-without-customersread).

A key that names nobody in this profile (unknown, erased, or another profile's customer) answers `404 customer_not_found`; a conversion handle that names nothing answers `404 conversion_not_found`. Both are distinct from `not_found`, which also means "this deployment does not serve that route".

## Customer Search

`GET /api/v1/customers/search` — find a customer by name, email or phone.

Built for a picker: an operator choosing which customer an unlinked
conversation or payment belongs to. A query shorter than **two characters
returns an empty list** rather than the whole book, so it is safe to call on
every keystroke behind a short debounce.

### Parameters

| Parameter | Type   | Description |
| --------- | ------ | ----------- |
| `q`       | string | Required. The search term. Under 2 characters returns `[]`. |
| `exclude` | uuid   | A customer id to leave out — typically the one already selected. |
| `limit`   | number | 1-50, default 20. |

Requires the `customers:read` scope, the same tier as the customer list.

Each candidate carries `customer_profile_id`, `first_name`, `last_name`,
`primary_email`, `primary_phone`, `first_seen_at`, `last_seen_at`,
`conversion_count`, `cash_revenue` and `sources`. `cash_revenue` is an exact
decimal **string** — parse it before doing arithmetic.

## Journey Summary

`GET /api/v1/customers/{id}/journey-summary` returns the stored AI summary of
one customer's journey, and never calls a model: a `null` summary with
`cached: false` means none has been generated yet.

`POST /api/v1/customers/{id}/journey-summary` generates one from the first 100
events of the timeline and stores it, replacing any previous summary. It draws
one unit from the workspace's daily AI allowance; when that is spent the call
answers `429` with a `Retry-After` naming the next UTC midnight. A customer
with no events returns a `null` summary and costs nothing.

A healthcare-family profile — its workspace is a healthcare agency, or the
profile runs in `platform_safe` or `hipaa` privacy mode — never sends a
patient's journey to a model: the call answers `403` `forbidden`, and so does a
profile whose status cannot be read.

`{id}` is the same visitor id the customer list publishes — a customer id, or
an anonymous id for a visitor who was never identified.

## Link Identity Tokens

```http title="Endpoint"
POST /api/v1/customers/link-tokens
```

Mints an `atb_t` token for each customer you name, to put on the links in
your emails, WhatsApp messages and CRM sequences. When the customer clicks —
on their phone, their laptop, any device — the Atribu tracker reads the token,
removes it from the address bar, and links that browser to the customer. The
visits it makes from then on, and the anonymous ones it already made, count
toward that customer.

**Scope:** `attribution:write`

<Callout type="info" title="No personal data in your links">
  The token is encrypted and signed. It never contains an email, a phone or any
  readable id; two tokens for the same person look unrelated; a token changed
  by even one character is ignored. It only works on this profile's site, and
  only until `expires_at`. This replaces putting `?email=` in a URL.
</Callout>

Only customers Atribu **already knows** get a token — a row that matches
nobody comes back `not_found`, and nothing is created. A browser that is
already linked to a different customer is never re-assigned by a token.

### Request body

| Field             | Type   | Required | Description |
| ----------------- | ------ | -------- | ----------- |
| `customers`       | array  | Yes      | Up to 500 rows. Each row names ONE customer (selectors below). |
| `expires_in_days` | number | No       | 1–365, default 90. Match the life of the campaign or sequence. |
| `country`         | string | No       | ISO 3166-1 alpha-2 default for phones without a `+` country code, and for `national_id`. |

Each row uses one selector. If a row carries several, the first in this order wins:

| Selector | Matches |
| -------- | ------- |
| `customer_profile_id` | Atribu's id for the customer. |
| `customer_key` | Your own id for the customer: the `user_traits.external_id` you send to `POST /api/v1/events`. Same as `identifier_type: "external_customer_id"`. |
| `email` | Case-insensitive. |
| `phone` | Normalised to E.164, then exact. |
| `identifier_type` + `identifier_value` | `external_customer_id`, `ghl_contact_id`, `shopify_customer_id`, `stripe_customer_id` or `national_id` (a Chilean RUT, any formatting). |

A selector that is present but malformed makes the row `invalid`. The row does not fall back to another selector.

```bash title="cURL"
curl -X POST "https://www.atribu.app/api/v1/customers/link-tokens" \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "expires_in_days": 30,
    "customers": [
      { "customer_key": "c9d2e8f1-cust-8841" },
      { "customer_key": "c9d2e8f1-cust-9107" }
    ]
  }'
```

### Response

```json title="Success response (200 OK)"
{
  "data": {
    "param": "atb_t",
    "minted": 1,
    "not_found": 1,
    "invalid": 0,
    "results": [
      {
        "index": 0,
        "status": "minted",
        "customer_key": "c9d2e8f1-cust-8841",
        "token": "AZ3kq0sV1mN8x4Lr7cTzYbQw2eJp6uHf9gDk5aSx0vWn3oIm8tRc1yUe4hGj7lKb2zXq6pNs9dMf0wVa3rTy5uIo8eQ",
        "expires_at": "2026-10-25T14:00:00.000Z"
      },
      {
        "index": 1,
        "status": "not_found",
        "customer_key": "c9d2e8f1-cust-9107",
        "reason": "no_match",
        "message": "No customer in this profile matches this row. Nothing was created."
      }
    ],
    "recipes": [
      { "channel": "gohighlevel", "store_token_in": "…", "link_template": "…", "merge_tag": "…", "requires": "…" }
    ]
  },
  "meta": { "profile_id": "0b6f1c2e-5d4a-4f3b-9e8d-7c6b5a4f3e2d" }
}
```

Every row carries `customer_key`: on a minted row, the key the customer holds (after a merge, the surviving customer's key); on any other row, the key you sent, or `null`. `customer_profile_id` appears on minted rows only for a key with `customers:read`. Partner keys on healthcare profiles never hold that scope, so they never receive it.

A row that did not mint carries a `reason` your screen can branch on, and an English `message`. For example, a row sent with an unsupported `identifier_type` comes back as:

```json title="A row that is invalid (excerpt of results[])"
{
  "index": 0,
  "status": "invalid",
  "customer_key": null,
  "reason": "unknown_identifier_type",
  "message": "identifier_type must be one of external_customer_id, ghl_contact_id, shopify_customer_id, stripe_customer_id, national_id."
}
```

All reasons:

| `status` | `reason` | Meaning |
| -------- | -------- | ------- |
| `not_found` | `no_match` | Nobody in this profile carries that selector. Nothing was created. |
| `not_found` | `erased` | The customer was erased. Only returned for a `customer_profile_id` you sent, or a `customer_key` you erased with [`POST /api/v1/customers/erasures`](#customer-erasure-by-your-key). An email or phone of an erased customer returns `no_match`. |
| `invalid` | `no_selector` | The row names no selector. |
| `invalid` | `unknown_identifier_type` | `identifier_type` is not one of the five listed above. |
| `invalid` | `malformed_selector` | A `customer_profile_id` that is not a UUID, or an email, phone or RUT that cannot be parsed. |

A row's `status: "not_found"` is a per-row result inside a `200` response, not an HTTP error code; the request itself succeeded.

When the deployment has no token secret configured, the endpoint answers `503` with code [`link_tokens_disabled`](/docs/api/errors#link_tokens_disabled). The feature is off, not down, so don't retry. Send your links without `atb_t`.

### Merge-tag recipes

No email or messaging tool can create a token at send time, so the setup is
the same two steps everywhere: **save each customer's token on their contact**
(from the response above), then **add the tool's merge tag to your links**.
If your link already has a `?`, use `&atb_t=` instead of `?atb_t=`.

| Tool | Save the token in | Link |
| ---- | ----------------- | ---- |
| GoHighLevel | Contact custom field `atribu_link_token` | `https://your-site.com/offer?atb_t={{contact.atribu_link_token}}` |
| Klaviyo | Profile property `atribu_link_token` | `https://your-site.com/offer?atb_t={{ person\|lookup:'atribu_link_token' }}` |
| WhatsApp template | The dynamic URL button variable | Button URL `https://your-site.com/offer?atb_t={{1}}`; your sender fills `{{1}}` with the token |
| Your own page (`partner_page`) | Nowhere: mint when you build the message | `https://your-app.com/quotes/q_48213?atb_t={{token}}` |

The Atribu tracker must be installed on the landing page. Links that point at
a page without it are not linked.

### Pages your app renders (`partner_page`)

A partner app that renders the page itself, such as a quote to accept or a booking to confirm, already knows the customer by its own key:

1. When you build the message, mint by `customer_key` and put the token on the link: `https://your-app.com/quotes/q_48213?atb_t=<token>`.
2. Serve the profile's tracker on that page. Serve it through a [same-origin collector](/docs/tracking/same-origin-collector), so the visitor id is a first-party cookie that Safari does not cap at 7 days.
3. When the customer opens the link, the tracker removes `atb_t` from the address bar and links the browser to the customer. Later visits from that browser, with or without a token, count toward them.

A tampered or expired token is ignored. The page still loads and the visit still counts, just not toward the customer.

## Customer erasure by your key

```http title="Endpoint"
POST /api/v1/customers/erasures
GET  /api/v1/customers/erasures/{erasure_id}
```

For a partner app that deletes a contact on its side, through its own archive flow or a data-subject request, and must cascade that erasure to Atribu. Call it when your contact is deleted and keep the receipt.

**Scope:** `attribution:write` (healthcare keys hold it). The attribution add-on is not required: a profile without it can still erase.

**API keys only.** A console session or an MCP user token gets `403 api_key_required`. To erase as a person, use `POST /api/v1/workspaces/{workspaceId}/customer-erasures` as a workspace owner or admin. The receipt records the API key that asked.

The erasure removes the customer's personal data from this profile: name, email, phone and every identifier, conversation content, and the personal fields of their raw events. Their conversions, touches and attribution stay, without a person attached, so reports and ROAS do not change. Afterwards:

- `GET /api/v1/customers/journey?customer_key=` for that key answers `404 customer_not_found`.
- Link tokens minted for the customer no longer link a browser, and minting by the key answers `not_found` with reason `erased`.

It cannot be undone. Conversions already delivered to Meta or Google are outside Atribu's reach.

### Request body

| Field | Type | Required | Description |
| ----- | ---- | -------- | ----------- |
| `request_ref` | string | Yes | Your idempotency key for this erasure, unique per profile. Letters, digits and `. _ : -`, up to 200 characters. Never put personal data in it; it is stored. |
| `customer_key` | string | One selector | Your own id for the customer (the `user_traits.external_id` you send). |
| `email` / `phone` | string | One selector | Exact match. `phone` is normalised to E.164; add `country` for numbers without `+`. |
| `identifier_type` + `identifier_value` | string | One selector | `external_customer_id`, `ghl_contact_id`, `shopify_customer_id`, `stripe_customer_id` or `national_id`. |
| `reason` | string | No | `data_subject_request` (default), `account_closed` or `other`. |

Name the customer with exactly one selector. A body with none, or with two, is refused, because an erasure cannot be undone.

```bash title="cURL"
curl -X POST "https://www.atribu.app/api/v1/customers/erasures" \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "request_ref": "crm:contact-archive:8f1a2c",
    "customer_key": "c9d2e8f1-cust-8841",
    "reason": "data_subject_request"
  }'
```

### Response

```json title="Erased (202 Accepted)"
{
  "data": {
    "erasure_id": "3f0b8c1e-6a2d-4c7b-9e15-2d8a4f6b1c90",
    "request_ref": "crm:contact-archive:8f1a2c",
    "status": "done",
    "customer_key": "c9d2e8f1-cust-8841",
    "reason": "data_subject_request",
    "requested_at": "2026-09-25T18:04:11.201Z",
    "completed_at": "2026-09-25T18:04:11.305Z"
  },
  "meta": { "profile_id": "0b6f1c2e-5d4a-4f3b-9e8d-7c6b5a4f3e2d" }
}
```

| Field | Description |
| ----- | ----------- |
| `erasure_id` | Keep it. `GET /api/v1/customers/erasures/{erasure_id}` (`attribution:write` or `conversions:read`) returns the same receipt. |
| `status` | `done` once every store is erased. `queued` while part of it is still completing in the background; the customer is already unreachable by your key. |
| `customer_key` | The key you sent, or the customer's key when you named them by email, phone or another identifier. `null` if they had none. Every key the customer held (after a merge, several) is recorded as erased. |
| `completed_at` | When `status` became `done`; `null` while `queued`. |

The receipt never carries `customer_profile_id`, for any key.

| Status | When |
| ------ | ---- |
| `202` | Erased now. |
| `200` | The same `request_ref` again for the same customer (the original receipt, and nothing more is erased), or a new `request_ref` for any key of a customer you already erased (the first receipt). |
| `400 invalid_parameter` | `request_ref` missing or malformed, or already used for a different customer; no selector or more than one; an unknown `identifier_type`; a selector that cannot be parsed; or an unknown `reason`. The message names the field. |
| `400 invalid_request` | The body is not a JSON object. |
| `403 api_key_required` | The credential is a console session or an MCP user token. |
| `404 customer_not_found` | No customer of this profile matches. Nothing was erased. A customer of another profile answers the same. |

`GET /api/v1/customers/erasures/{erasure_id}` answers `404 erasure_not_found` for an id that is not a receipt of this profile. Both codes are distinct from `not_found`, which also means "this deployment does not serve that route", so branch on the code, not the status.

Receipts live with the profile: deleting the profile deletes them. Atribu's own audit record of each erasure stays.

## Related

<Cards>
  <Card title="Authentication" href="/docs/api/authentication">
    API key setup, scopes (customers:read requires explicit grant)
  </Card>
  <Card title="Customer Journeys" href="/docs/features/customer-journey">
    The dashboard view for exploring customer paths
  </Card>
  <Card title="Identity Resolution" href="/docs/concepts/identity-resolution">
    How customer profiles are built from multiple identifiers
  </Card>
</Cards>

---

# Identity

Atribu resolves an anonymous visitor into a known customer from the identifiers
it sees: an `anonymous_id`, an email, a phone, a CRM contact id, a national id.
When two customer profiles turn out to be the same person, they are **merged**.

Merging is destructive-adjacent — it moves conversions, touches and revenue
from one profile onto another — so it is a **review queue**, not an automatic
sweep.

## The review queue

```http title="Endpoints"
POST /api/v1/identity/merge-proposals/scan
GET  /api/v1/identity/merge-proposals
GET  /api/v1/identity/merge-proposals/{id}/preview
POST /api/v1/identity/merge-proposals/{id}/decide
GET  /api/v1/identity/merge-history
```

Each proposal is a **pair** of customer profiles a scan believes are the same
person, with both sides' identity and the evidence for the match. `preview`
shows exactly what a merge would move; `decide` accepts or rejects it.

**Keyset paginated.** Pass `pagination.cursor` to walk older pages; an
unreadable cursor starts from the beginning rather than erroring. There is no
total — the queue is being resolved while you walk it, so an offset page would
shift under you. `status_counts` rides alongside `data` and describes the
*whole* queue, ignoring the current page's filters.

## The repair scan

```http title="Endpoints"
GET  /api/v1/identity/repair-scan
POST /api/v1/identity/repair
```

`repair-scan` scores every non-merged customer profile: how many are missing a
primary email or phone, how many duplicate-email/phone groups exist, and — per
profile — what a backfill would write. Offset-paginated, because the underlying
scan is a single pass.

<Callout type="warn" title="`auto_repairable_count` counts backfills only">
Merges left the bulk repair path and go through the proposal queue instead.
`auto_merge_count` is reported for awareness and is **not** something
`POST /api/v1/identity/repair` will act on.
</Callout>

## Why a merge cannot be undone by re-scanning

A profile created from an identifier alone — a `ghl_contact_id` with no email,
say — is an orphan the later PII event can never absorb, because the identifier
upsert never re-points an existing mapping. That is why every ingestion path
resolves identity from real PII where it has it and only *reads* the identity
graph where it does not. The consequence for a consumer of this API: a merge
proposal you reject stays rejected, and the two profiles keep accumulating
separately.

{/* BEGIN GENERATED ENDPOINT TABLE — generated by scripts/docs/generate-api-docs.ts from openapi.json. Do not hand-edit between these markers; run `npx tsx scripts/docs/generate-api-docs.ts`. */}

| Method | Path | What it does |
|---|---|---|
| `GET` | `/api/v1/identity/links` | List identity links (inferred-link review) |
| `POST` | `/api/v1/identity/links/{id}/decide` | Accept or reject an identity link |
| `GET` | `/api/v1/identity/merge-history` | List past merges into a customer |
| `GET` | `/api/v1/identity/merge-preview` | Preview merging two customers |
| `GET` | `/api/v1/identity/merge-proposals` | List suggested customer merges |
| `POST` | `/api/v1/identity/merge-proposals/{id}/decide` | Merge, dismiss or snooze a suggested merge |
| `GET` | `/api/v1/identity/merge-proposals/{id}/preview` | Preview what a suggested merge would move |
| `POST` | `/api/v1/identity/merge-proposals/scan` | Re-run the suggested-merge proposer |
| `POST` | `/api/v1/identity/merges/{auditLogId}/undo` | Undo a merge |
| `POST` | `/api/v1/identity/repair` | Preview or apply identity repairs |
| `GET` | `/api/v1/identity/repair-scan` | Audit customer profiles for missing or duplicated identifiers |

Generated from `openapi.json`. The full request and response schema for every operation is in the [OpenAPI document](https://www.atribu.app/api/v1/openapi.json).

{/* END GENERATED ENDPOINT TABLE */}

## Next steps

<Cards>
  <Card title="Identity resolution" href="/docs/concepts/identity-resolution">
    How an anonymous visitor becomes a known customer
  </Card>
  <Card title="Customers" href="/docs/api/customers">
    Reading a customer and their journey
  </Card>
  <Card title="Identify users" href="/docs/tracking/identify-users">
    The tracker call that starts the stitch
  </Card>
</Cards>

---

# Visitors & Realtime

Four endpoints for visitor data, realtime presence, search keywords, and attribution quality metrics.

---

## Visitor List

```http title="Endpoint"
GET /api/v1/visitors
```

Paginated list of all visitors (identified and anonymous). Returns PII fields like name and email.

**Scope:** `visitors:read` (must be explicitly granted)

<Callout type="error" title="PII access required">
This endpoint returns personally identifiable information (name, email). Your API key must have the `visitors:read` scope explicitly granted. Keys with only `analytics:read` will receive a `403 Forbidden` response.
</Callout>

### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `date_from` | string | Yes | Start date (YYYY-MM-DD) |
| `date_to` | string | Yes | End date (YYYY-MM-DD) |
| `search` | string | No | Search by name or email |
| `limit` | number | No | Results per page (default 10, max 100) |
| `cursor_time` | string | No | Pagination cursor (ISO timestamp) |
| `cursor_id` | string | No | Pagination cursor ID |

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="Request"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/visitors?date_from=2026-03-01&date_to=2026-03-25&limit=5"
```
</Tab>
<Tab value="JavaScript">
```ts title="Request"
const response = await fetch(
  "https://www.atribu.app/api/v1/visitors?date_from=2026-03-01&date_to=2026-03-25&limit=5",
  {
    headers: {
      Authorization: "Bearer atb_live_YOUR_KEY",
    },
  }
);
const data = await response.json();
```
</Tab>
<Tab value="Python">
```python title="Request"
import requests

resp = requests.get(
    "https://www.atribu.app/api/v1/visitors",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={
        "date_from": "2026-03-01",
        "date_to": "2026-03-25",
        "limit": 5,
    },
)
data = resp.json()
```
</Tab>
</Tabs>

### Response

```json title="Success response (200 OK)"
{
  "data": [
    {
      "visitor_id": "uuid",
      "customer_profile_id": "uuid",
      "name": "John Doe",
      "email": "john@example.com",
      "country": "US",
      "device": "desktop",
      "browser": "Chrome",
      "os": "macOS",
      "channel": "Organic Search",
      "source": "google",
      "total_revenue": 450.00,
      "last_seen_at": "2026-03-25T08:30:00Z",
      "session_count": 5,
      "total_pageviews": 18,
      "touch_channels": ["Organic Search", "Direct", "Paid Social"]
    }
  ],
  "pagination": {
    "has_next": true,
    "cursor": "2026-03-25T08:30:00Z|uuid"
  },
  "meta": {
    "date_from": "2026-03-01",
    "date_to": "2026-03-25",
    "profile_id": "uuid"
  }
}
```

### Response fields

| Field | Type | Description |
|-------|------|-------------|
| `visitor_id` | string | Unique visitor identifier (anonymous_id) |
| `customer_profile_id` | string \| null | Linked customer profile (null for anonymous visitors) |
| `name` | string | Display name or deterministic anonymous name (e.g. "Teal Falcon") |
| `email` | string \| null | Email address if identified |
| `country` | string | Two-letter country code |
| `device` | string | Device type: `desktop`, `mobile`, `tablet` |
| `browser` | string | Browser name |
| `os` | string | Operating system |
| `channel` | string | Classified traffic channel (see [Channels](/docs/concepts/channels)) |
| `source` | string | Traffic source |
| `total_revenue` | number | Total attributed cash revenue |
| `last_seen_at` | string | ISO timestamp of last activity |
| `session_count` | number | Total sessions in the date range |
| `total_pageviews` | number | Total page views in the date range |
| `touch_channels` | string[] | All channels this visitor has interacted through |

---

## Realtime

```http title="Endpoint"
GET /api/v1/realtime
```

Returns the number of visitors currently on your site (active in the last 5 minutes).

**Scope:** `realtime:read`

<Callout type="info" title="No date parameters">
This endpoint returns a live snapshot. No `date_from` or `date_to` parameters are needed.
</Callout>

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="Request"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/realtime"
```
</Tab>
<Tab value="JavaScript">
```ts title="Request"
const response = await fetch(
  "https://www.atribu.app/api/v1/realtime",
  {
    headers: {
      Authorization: "Bearer atb_live_YOUR_KEY",
    },
  }
);
const data = await response.json();
```
</Tab>
<Tab value="Python">
```python title="Request"
import requests

resp = requests.get(
    "https://www.atribu.app/api/v1/realtime",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
)
data = resp.json()
```
</Tab>
</Tabs>

### Response

```json title="Success response (200 OK)"
{
  "data": {
    "visitors_online": 24
  },
  "meta": {
    "profile_id": "uuid"
  }
}
```

---

## Keywords

```http title="Endpoint"
GET /api/v1/keywords
```

Returns Google Search Console keyword performance data. Requires an active GSC integration.

**Scope:** `analytics:read`

### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `date_from` | string | Yes | Start date (YYYY-MM-DD) |
| `date_to` | string | Yes | End date (YYYY-MM-DD) |
| `limit` | number | No | Max results (default 50, max 100) |

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="Request"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/keywords?date_from=2026-03-01&date_to=2026-03-25&limit=10"
```
</Tab>
<Tab value="JavaScript">
```ts title="Request"
const response = await fetch(
  "https://www.atribu.app/api/v1/keywords?date_from=2026-03-01&date_to=2026-03-25&limit=10",
  {
    headers: {
      Authorization: "Bearer atb_live_YOUR_KEY",
    },
  }
);
const data = await response.json();
```
</Tab>
<Tab value="Python">
```python title="Request"
import requests

resp = requests.get(
    "https://www.atribu.app/api/v1/keywords",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={
        "date_from": "2026-03-01",
        "date_to": "2026-03-25",
        "limit": 10,
    },
)
data = resp.json()
```
</Tab>
</Tabs>

### Response

```json title="Success response (200 OK)"
{
  "data": [
    {
      "keyword": "marketing attribution tool",
      "impressions": 2400,
      "clicks": 180,
      "ctr": 7.5,
      "avg_position": 4.2
    },
    {
      "keyword": "roas calculator",
      "impressions": 1800,
      "clicks": 95,
      "ctr": 5.3,
      "avg_position": 6.8
    }
  ],
  "meta": {
    "date_from": "2026-03-01",
    "date_to": "2026-03-25",
    "profile_id": "uuid"
  }
}
```

### Response fields

| Field | Type | Description |
|-------|------|-------------|
| `keyword` | string | Search query term |
| `impressions` | number | Times your site appeared in search results |
| `clicks` | number | Times users clicked through to your site |
| `ctr` | number | Click-through rate (clicks / impressions x 100) |
| `avg_position` | number | Average ranking position in search results |

---

## Quality

```http title="Endpoint"
GET /api/v1/quality
```

Returns attribution data quality metrics -- how well your tracking captures marketing source data.

**Scope:** `analytics:read`

### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `date_from` | string | Yes | Start date (YYYY-MM-DD) |
| `date_to` | string | Yes | End date (YYYY-MM-DD) |

### Request

<Tabs items={["cURL", "JavaScript", "Python"]}>
<Tab value="cURL">
```bash title="Request"
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/quality?date_from=2026-03-01&date_to=2026-03-25"
```
</Tab>
<Tab value="JavaScript">
```ts title="Request"
const response = await fetch(
  "https://www.atribu.app/api/v1/quality?date_from=2026-03-01&date_to=2026-03-25",
  {
    headers: {
      Authorization: "Bearer atb_live_YOUR_KEY",
    },
  }
);
const data = await response.json();
```
</Tab>
<Tab value="Python">
```python title="Request"
import requests

resp = requests.get(
    "https://www.atribu.app/api/v1/quality",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={
        "date_from": "2026-03-01",
        "date_to": "2026-03-25",
    },
)
data = resp.json()
```
</Tab>
</Tabs>

### Response

```json title="Success response (200 OK)"
{
  "data": {
    "total_events": 1450,
    "with_full_utms": 890,
    "with_fbclid_only": 320,
    "with_no_tracking": 240,
    "coverage_percent": 83.45
  },
  "meta": {
    "date_from": "2026-03-01",
    "date_to": "2026-03-25",
    "profile_id": "uuid"
  }
}
```

### Response fields

| Field | Type | Description |
|-------|------|-------------|
| `total_events` | number | Total tracked events in the period |
| `with_full_utms` | number | Events with complete UTM parameters |
| `with_fbclid_only` | number | Events with only a click ID (fbclid/gclid) but no UTMs |
| `with_no_tracking` | number | Events with no marketing source data at all |
| `coverage_percent` | number | Percentage of events that have at least some attribution data |

<Callout type="info" title="Improving coverage">
A low `coverage_percent` usually means UTM parameters are missing from your ad URLs. Ensure all campaigns include `utm_source`, `utm_medium`, and `utm_campaign` parameters. Click IDs (`fbclid`, `gclid`) are added automatically by ad platforms.
</Callout>

## Related

<Cards>
  <Card title="Authentication" href="/docs/api/authentication">
    API key setup, scopes (visitors:read requires explicit grant)
  </Card>
  <Card title="Data Quality" href="/docs/features/data-quality">
    Monitor and improve your tracking coverage
  </Card>
  <Card title="Customer Journeys" href="/docs/features/customer-journey">
    The dashboard equivalent of the journey endpoint
  </Card>
</Cards>

---

# Recommendations

Scale a winner, pause an underperformer, reallocate budget, refresh a creative
before fatigue lands. `GET /api/v1/recommendations` lists the open ones for the
key's profile; the workspace-wide view is the MCP tool
`list_workspace_recommendations`.

All list parameters are comma-separated and validated **strictly** — an unknown
value is a `400`, never a silent drop. A filter that silently drops an unknown
value returns a *different* set than the caller asked for and says nothing.


## What a recommendation is based on

A recommendation **attests**; it does not predict. Atribu makes one because an
ad has *already* closed sales it could attribute — at least **3** of them — and
its cost per sale is at least **10%** away from that client's own median. Every
number in `rationale.evidence` is reproducible by the advertiser: ad spend from
Meta Ads Manager, the sale count and value from their payment provider.

A "sale" is a cash payment (`revenue_type = 'cash'`), never a lead. The cash
kinds carry `window_days`, `attributed_cash_outcomes`, `cost_per_cash_outcome`,
`profile_median_cost_per_cash_outcome`, `cost_delta_pct` (negative = cheaper
than the median), `spend` and `currency`. `ad_name` is on the row — render it,
never `target.ad_id`.

`rationale.text` is OPTIONAL commentary written by the arbiter, not the
argument. Compose the sentence a reader sees from `rationale.evidence`.

`expected_impact_dollars` is arithmetic on that ad's own observed unit
economics over the next 7 days, under the stated assumption that extra budget
performs like the window did. Phrase it with **"if"**, never "expected",
"forecast" or "at risk".

Expect the list to be **short**. The three-sale floor is a minimum-evidence
rule, not a tuned threshold, and the binding constraint on how many
recommendations exist is cash-attribution coverage, not the criterion.
## Applying one

```http title="Endpoint"
POST /api/v1/recommendations/{id}/apply
```

Executes through the meta-actions layer: **one write spine, one audit trail
(`meta_action_log`), one rollback system**. There is no queue and nothing to
poll — the response says what happened.

<Callout type="warn" title="`Idempotency-Key` is required">
1–256 characters. Leg-level dedup is automatic (the executor derives its keys
from the recommendation id), so retrying an already-applied recommendation
answers `200` with `replayed: true` rather than double-spending a budget.
</Callout>

`GET /api/v1/recommendations/{id}/diagnose` answers *why did this
recommendation do what it did* — the read to make before explaining an applied
change to a client, and the one to make when it did not do what you expected.

`POST /api/v1/recommendations/{id}/dismiss` closes one without applying it.

## When a human should approve first

`require_approval: true` on the apply mints an `approve`
[hand-off](/docs/api/handoffs) carrying the **exact preview**, and answers
`202` with `{status: "awaiting_approval", handoff: {id, url, expires_at}}`
having written **nothing**. The human opens the URL signed out, sees what
changes and on which account, and presses Approve or Reject; your agent polls
`GET /api/v1/handoffs/{id}` for the same result payload the direct call
returns.

<Callout type="warn" title="Approval is OR'd, never AND'd">
It is required when the principal is **not** a workspace owner/admin, **or**
whenever any caller passes `require_approval: true`. The second is the agency
case: an owner/admin token deliberately routing every Meta write on a client's
account past the client. Rejecting or expiring leaves no partial write, and the
pending action's own row is the audit record — `status` plus
`result.outcome` ∈ `approved | rejected | expired | failed`.
</Callout>

Over MCP, `apply_recommendation`'s `confirm` mints the same object under the
same rule.

Until then, the MCP tool's `preview → dry_run → confirm` ceremony plus the
workspace-level `mcp_writeback_enabled` gate is the safety boundary — see
[Write-back](/docs/mcp/write-back).

{/* BEGIN GENERATED ENDPOINT TABLE — generated by scripts/docs/generate-api-docs.ts from openapi.json. Do not hand-edit between these markers; run `npx tsx scripts/docs/generate-api-docs.ts`. */}

| Method | Path | What it does |
|---|---|---|
| `GET` | `/api/v1/recommendations` | List open recommendations for this key's profile |
| `POST` | `/api/v1/recommendations/{id}/apply` | Apply a recommendation (inline, terminal response) |
| `GET` | `/api/v1/recommendations/{id}/diagnose` | Why did this recommendation do what it did |
| `POST` | `/api/v1/recommendations/{id}/dismiss` | Dismiss a recommendation |

Generated from `openapi.json`. The full request and response schema for every operation is in the [OpenAPI document](https://www.atribu.app/api/v1/openapi.json).

{/* END GENERATED ENDPOINT TABLE */}

## Next steps

<Cards>
  <Card title="AI Media Buyer" href="/docs/features/ai-media-buyer">
    What produces these recommendations
  </Card>
  <Card title="MCP write-back" href="/docs/mcp/write-back">
    preview → dry_run → confirm, and the circuit breaker
  </Card>
  <Card title="Hand-offs" href="/docs/api/handoffs">
    The approval object an agent polls
  </Card>
</Cards>

---

# Reports

A **report** is a rendered, brandable artifact built from a profile's
attribution data: generated on demand or on a schedule, delivered by email or
shared by link.

Four objects, in the order you meet them:

| object | route family | what it is |
|---|---|---|
| template | `/reports/templates` | which sections, which metrics, which branding |
| schedule | `/reports/schedules` | when it runs and who receives it |
| generated report | `/reports`, `/reports/{id}` | one rendered instance |
| delivery | `/reports/deliveries` | one attempt to get it to someone |

## Preview before you schedule

```http title="Endpoints"
POST /api/v1/reports/preview
POST /api/v1/reports/send-test
```

`preview` builds the report **data** without storing anything — the right call
when you are still deciding what a template should contain. `send-test` sends
one real email to you, which is the only way to check branding, the sender
domain and how it renders in a client's inbox.

## Generating and sending

```http title="Endpoints"
POST /api/v1/reports/generate
GET  /api/v1/reports/{id}
POST /api/v1/reports/{id}/send
```

Generation is separate from sending on purpose: an agency reviews before a
client sees it.

## Sharing without a credential

```http title="Endpoint"
GET /api/v1/public/reports/{share_slug}
```

The one route on this API with **no credential at all**. The slug is the
capability — treat it like a password: anyone holding it reads the report.

## Across many profiles at once

An agency's bulk operations are workspace-scoped and live under `/workspaces`:

- `GET /api/v1/workspaces/{workspaceId}/reports/hub` — the hub view
- `POST /api/v1/workspaces/{workspaceId}/reports/bulk-generate`
- `POST /api/v1/workspaces/{workspaceId}/reports/schedules/bulk`

Branding and the verified sending domain are also workspace-level — see
[Workspaces & profiles](/docs/api/workspaces).

{/* BEGIN GENERATED ENDPOINT TABLE — generated by scripts/docs/generate-api-docs.ts from openapi.json. Do not hand-edit between these markers; run `npx tsx scripts/docs/generate-api-docs.ts`. */}

| Method | Path | What it does |
|---|---|---|
| `GET` | `/api/v1/public/reports/{share_slug}` | Fetch a shared report (no credential) |
| `GET` | `/api/v1/reports` | List generated reports |
| `GET` | `/api/v1/reports/{id}` | Fetch a generated report |
| `DELETE` | `/api/v1/reports/{id}` | Delete a generated report |
| `POST` | `/api/v1/reports/{id}/send` | Email a generated report |
| `POST` | `/api/v1/reports/commentary/assist` | Improve a report's written commentary |
| `GET` | `/api/v1/reports/deliveries` | List report deliveries |
| `POST` | `/api/v1/reports/generate` | Generate a report |
| `POST` | `/api/v1/reports/preview` | Preview report data without storing it |
| `GET` | `/api/v1/reports/schedules` | Get the report schedule |
| `POST` | `/api/v1/reports/schedules` | Create or update the report schedule |
| `PATCH` | `/api/v1/reports/schedules/{id}` | Update the report schedule |
| `DELETE` | `/api/v1/reports/schedules/{id}` | Delete the report schedule |
| `GET` | `/api/v1/reports/schedules/changes` | List report schedule changes |
| `POST` | `/api/v1/reports/send-test` | Send a test report email |
| `GET` | `/api/v1/reports/templates` | List report templates |
| `POST` | `/api/v1/reports/templates` | Create a report template |
| `PATCH` | `/api/v1/reports/templates/{id}` | Update a report template |
| `DELETE` | `/api/v1/reports/templates/{id}` | Delete a report template |

Generated from `openapi.json`. The full request and response schema for every operation is in the [OpenAPI document](https://www.atribu.app/api/v1/openapi.json).

{/* END GENERATED ENDPOINT TABLE */}

## Next steps

<Cards>
  <Card title="Reports in the app" href="/docs/features/reports">
    Templates, branding and scheduling by hand
  </Card>
  <Card title="Workspaces & profiles" href="/docs/api/workspaces">
    Branding, sending domain, and bulk operations
  </Card>
</Cards>

---

# Webhooks

Atribu fans out events to HTTPS endpoints you register. Before #1049 the whole
platform was poll-only: an agent that started an OAuth connect, queued a
recompute or fired an export had no way to learn it landed except by asking
again.

## Subscribing

```http title="Endpoints"
GET    /api/v1/webhooks/subscriptions
POST   /api/v1/webhooks/subscriptions
PATCH  /api/v1/webhooks/subscriptions/{id}
DELETE /api/v1/webhooks/subscriptions/{id}
```

A subscription names a `url`, a list of `events`, and a list of `providers`. A
delivery is matched on **both** lists — an event whose provider your
subscription does not name is not delivered, whatever its event type.

<Callout type="warn" title="Platform lifecycle events are opt-in, and the trap is the provider list">
Every lifecycle event below carries the provider `atribu`, not a channel — a
recompute is not WhatsApp, and a `connection.connected` for `meta_ads` is not
any messaging channel either. So an existing subscription whose `providers` is
`["whatsapp"]` receives **none** of them until it `PATCH`es `atribu` in. The
connector's real provider rides in `data.provider`.
</Callout>

## The platform lifecycle events

| event | fires when |
|---|---|
| `connection.connected` | a provider connect completed |
| `connection.reconnect_required` | a connection stopped delivering and needs re-authorizing |
| `connection.revoked` | a connection was removed |
| `handoff.completed` | a [hand-off](/docs/api/handoffs) you minted was finished by a human |
| `recompute.completed` | an attribution recompute finished |
| `conversion.attributed` | **once** per conversion definition, on its first attributed conversion |
| `export.completed` / `export.failed` | a Conversion Sync export run settled |
| `profile.freshness.changed` | the profile's attribution freshness moved |
| `outcome.recorded` | a GoHighLevel or MercadoPago outcome was recorded on a profile your app provisioned |
| `leadgen.received` | a Meta Instant Form was submitted on a Page the profile connected |

`conversion.attributed` firing exactly once, durably, is what makes it usable as
"the setup worked" — it is stamped on the definition row by the projector, never
recomputed by a scan.

## Instant Form leads: `leadgen.received`

When a person submits a Meta Instant Form on a Page the profile connected,
Atribu reads the lead from the Graph API and delivers it as one
`leadgen.received` event (provider `atribu`, so opt-in). `data` is the Graph
lead merged over Meta's webhook value, plus `profile_id`; `id` is the
`leadgen_id`, so de-dupe on it. Meta prefixes phone answers with `p:`.

```json title="leadgen.received"
{
  "id": "444444444444001",
  "type": "leadgen.received",
  "occurred_at": "2026-10-11T15:00:00.000Z",
  "provider": "atribu",
  "data": {
    "profile_id": "<profile id>",
    "page_id": "109876543210987",
    "leadgen_id": "444444444444001",
    "form_id": "1234567890123456",
    "ad_id": "120211000000000401",
    "campaign_id": "120211000000000201",
    "field_data": [{ "name": "phone_number", "values": ["p:+56912345678"] }]
  }
}
```

## Provider outcomes: `outcome.recorded`

Each GoHighLevel outcome (a lead, a booking, a won or lost deal — whatever the
[stage mapping](/docs/getting-started/connect-crm) says) and each MercadoPago
payment recorded on a profile your app provisioned arrives as one
`outcome.recorded` event:

```json title="outcome.recorded"
{
  "id": "00000000-0000-4000-8000-00000000000a",
  "type": "outcome.recorded",
  "occurred_at": "2026-09-29T10:00:00.000Z",
  "app_id": "<your app id>",
  "provider": "atribu",
  "connection_id": null,
  "data": {
    "profile_id": "<profile id>",
    "provider": "gohighlevel",
    "outcome_type": "lead_created",
    "occurred_at": "2026-09-29T10:00:00.000Z",
    "recorded_at": "2026-09-29T10:00:04.000Z",
    "external_id": "<provider's id for the outcome>",
    "outcome_event_id": "<Atribu's id for the outcome>",
    "conversion_id": "<the conversion it produced, or null>",
    "customer_key": "<provider's customer id>",
    "value": null,
    "contact": { "first_name": "…", "last_name": "…", "email": "person@example.com", "phone": "+15555550100" },
    "contact_withheld": false
  }
}
```

- **`id` is stable per outcome.** Retries, a re-sync or an attribution
  recompute never produce a second event for the same outcome. De-dupe on it.
- **Delivery needs a live grant** for your app on the profile.
- **`contact`** is included only when that grant carries `customers:read`.
  Without it, `contact` is `null`, `contact_withheld` is `true`, and a
  `customer_key` that is not the provider's own opaque id (an email, any phone
  notation) is `null`. The person is
  read at each delivery attempt, so a retry after the customer was erased
  carries no person.
- **`outcome_event_id` / `conversion_id`** are the ids
  `GET /api/v1/campaigns/{id}/conversions` returns for the same outcome, so an
  event joins to the campaign credited for it. `conversion_id` is `null` when
  the outcome produced no conversion, or none yet: conversions are extracted
  asynchronously, so an early delivery can carry `null` where the replay later
  carries the id. `outcome_event_id` always joins.
- **Healthcare profiles reach only the app that provisioned them**: for a
  healthcare workspace, or a profile in Platform-Safe or HIPAA privacy mode,
  the event goes to the app that provisioned the profile and to no other. That
  app gets the person when its grant on the profile opens contact (a grant
  holding `customers:read`, `analytics_pii` or `attribution`, the webhook's
  usual rule), on the webhook and on the replay alike, although its key carries
  no `customers:read` for such a profile. The replay answers that app's key
  only; any other, whatever its scopes, is `403 healthcare_scope_forbidden`.
- A first sync records historical outcomes too: filter on `data.occurred_at`
  to skip them.

**Order and replay.** Order is guaranteed only for the replay:
`GET /api/v1/outcomes/recorded` (`conversions:read` or `customers:read`) returns
the same events with the same ids, in one stable order, from an opaque cursor.
The webhook itself is at-least-once and can arrive out of order: drains can
overlap, a failed row is retried later, and each delivery has its own retry
schedule. De-dupe on `id`, and reconcile through the replay. Store `pagination.cursor` (it is present on the last page too) and pass
it back later; `has_next: false` means you are caught up. Use it to backfill a
new subscriber or to reconcile after downtime. Events stay replayable for 90
days after delivery; an erased customer's events are not replayed.

The messaging and commerce event types (`message.received`,
`order.status.changed`, `catalog.updated`, …) are documented with the surfaces
that produce them — see the [Node SDK reference](/docs/sdks/node).

## Secrets, tests and replays

```http title="Endpoints"
POST /api/v1/webhooks/subscriptions/{id}/rotate-secret
POST /api/v1/webhooks/test/{id}
POST /api/v1/webhooks/deliveries/{id}/replay
```

`rotate-secret` is an **atomic dual-key rotation with a grace window**: the old
secret keeps verifying while you deploy the new one, so a rotation is not an
outage. `test/{id}` fires a synthetic, correctly-signed event — the right way to
prove your verifier works before real traffic depends on it. `replay` re-enqueues
an existing delivery payload rather than fabricating a new one, so a consumer
that was down gets the actual bytes it missed.

Every delivery is HMAC-signed; verify the signature before trusting the body.
The runbook for adding a consumer is
[`docs/external-webhooks.md`](https://github.com/AtribuCore/atribu/blob/main/docs/external-webhooks.md).

{/* BEGIN GENERATED ENDPOINT TABLE — generated by scripts/docs/generate-api-docs.ts from openapi.json. Do not hand-edit between these markers; run `npx tsx scripts/docs/generate-api-docs.ts`. */}

| Method | Path | What it does |
|---|---|---|
| `GET` | `/api/v1/outcomes/recorded` | Replay outcome.recorded events |
| `POST` | `/api/v1/webhooks/deliveries/{id}/replay` | Replay a webhook delivery |
| `GET` | `/api/v1/webhooks/subscriptions` | List webhook subscriptions |
| `POST` | `/api/v1/webhooks/subscriptions` | Create a webhook subscription |
| `PATCH` | `/api/v1/webhooks/subscriptions/{id}` | Update a webhook subscription |
| `DELETE` | `/api/v1/webhooks/subscriptions/{id}` | Delete a webhook subscription |
| `POST` | `/api/v1/webhooks/subscriptions/{id}/rotate-secret` | Rotate a subscription's HMAC secret |
| `POST` | `/api/v1/webhooks/test/{id}` | Fire a synthetic test event |

Generated from `openapi.json`. The full request and response schema for every operation is in the [OpenAPI document](https://www.atribu.app/api/v1/openapi.json).

{/* END GENERATED ENDPOINT TABLE */}

## Next steps

<Cards>
  <Card title="Step 13 — What next" href="/docs/getting-started/what-next">
    Staying set up: webhooks, readiness, and the ongoing loop
  </Card>
  <Card title="Hand-offs" href="/docs/api/handoffs">
    What `handoff.completed` is announcing
  </Card>
  <Card title="Node SDK" href="/docs/sdks/node">
    Signature verification, and the messaging event payloads
  </Card>
</Cards>

---

# Messaging & Social

Atribu is an **OAuth provider** for messaging as well as an attribution engine.
A sibling application that needs to send a WhatsApp template, reply to an
Instagram comment or read a conversation thread does it here, against the
merchant's own connection, instead of registering its own Meta App.

That surface is large — WhatsApp registration, templates, broadcasts, flows,
calling and health; Instagram media, triggers and conversations; email;
calendar; the unified inbox — and it is documented **in depth in the
[Node SDK reference](/docs/sdks/node)**, which carries the payload shapes,
the send semantics and the webhook verification. This page is the route index
and the map into it.

## Where to read what

| you want to | read |
|---|---|
| send a message, reply to a comment, verify a webhook | [Node SDK](/docs/sdks/node) |
| know which events fan out and how to subscribe | [Webhooks](/docs/api/webhooks) |
| attribute a WhatsApp conversation to the ad that caused it | [Traffic sources](/docs/features/traffic-sources) and [Conversion Sync](/docs/features/conversion-sync) |
| understand the scopes these routes need | [Authentication](/docs/api/authentication) |

## Two rules that catch every new consumer

<Callout type="warn" title="A messaging scope is not an analytics scope">
`mcp:read` maps to `analytics:read` + `campaigns:read` and reaches **no**
messaging route. Messaging requires its own scopes, granted per connection, and
a send is additionally gated by an `oauth_app_authorizations` row for that
connection — an app that holds the scope but not the authorization is refused.
</Callout>

<Callout type="warn" title="Instagram: the login id is not the user id">
Webhooks deliver the IG **user_id**; the content APIs address the **id**. They
are different numbers for the same account, and using one where the other
belongs fails as an empty result rather than an error.
</Callout>

## Retrying a send safely — `Idempotency-Key`

A send that answers with a transport error, a 502/504 or a timeout may or may
not have reached Meta. Retrying it blind can deliver the message twice. Send an
**`Idempotency-Key`** header and the retry is safe:

```bash
curl -X POST https://api.atribu.app/api/v1/messages \
  -H "Authorization: Bearer atb_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8f2b1c04-3a9e-4a51-9e0d-2c7b6f10a3d5" \
  -d '{"connection_id":"...","channel":"whatsapp","to":"56911112222","content":{"type":"text","text":"Hola"}}'
```

| | |
|---|---|
| **Where** | `POST /api/v1/messages` (WhatsApp, Instagram and email), `POST /api/v1/conversations/{id}/messages`, `POST /api/v1/whatsapp/conversations/{id}/messages` |
| **Window** | **24 hours** from the first request. After that the key is free again and the same value starts a new send. |
| **Scope** | Your **API key** + the **route** + the key value. Two partners can use the same key string without colliding, and a key claimed on one route is never replayed on another. |
| **Format** | 1–256 characters, any value you generate. Use something unique per logical message — Vitrina uses its own message id. |

**What each repeat does.**

- **Same key, first request finished, inside 24 h** — the original response is
  replayed **verbatim** (same status, same `provider_message_id`, same
  `sent_at`) and **nothing is sent**.
- **Same key, first request still in flight** — `409 idempotency_key_in_flight`.
  The first attempt may still deliver, so the retry is refused rather than
  risked. **Retry the same key with backoff**; once the original finishes, that
  key replays its response for the rest of the window.
- **Same key, different body** — `409 idempotency_key_conflict`. Reusing one key
  for two different messages is a caller bug, and replaying the first message's
  response would silently swallow the second. **Not retryable as sent**: resend
  the original body under that key, or mint a fresh key for the new message.
  Everything that decides what the customer receives is compared — the channel,
  the connection, the recipient and the content.
- **No header at all** — every request sends. The header is opt-in and changes
  nothing for a consumer that does not use it.

<Callout type="warn" title="Only a success is remembered">
A non-2xx response releases the key, so a send that genuinely failed can be
retried under the same value. The flip side: if Atribu answered an error
*after* Meta had already accepted the message, that retry will send again.
Retry on errors that provably never reached Meta (connection refused, a
timeout before headers, a 503 from Atribu's own auth layer) and let the
`Idempotency-Key` cover the rest.
</Callout>

The [Node SDK](/docs/sdks/node) generates a key for every mutating `POST`
automatically. Pass your own — the one you will reuse on a retry — as
`idempotencyKey`:

```ts
await client.messages.send(input, { idempotencyKey: message.id });
await client.conversations.sendMessage(id, { text }, { idempotencyKey: message.id });
```

## Organic and DM attribution

`/api/v1/organic/*` and `/api/v1/dm-ads*` are the attribution reads for content
that is not a paid click: organic posts and stories with their attributed
outcomes, and the DM-ads cohort. `GET /api/v1/profile/dark-messaging-spend`
estimates the revenue at risk from Instagram DM conversations that carry no
attribution at all.

{/* BEGIN GENERATED ENDPOINT TABLE — generated by scripts/docs/generate-api-docs.ts from openapi.json. Do not hand-edit between these markers; run `npx tsx scripts/docs/generate-api-docs.ts`. */}

| Method | Path | What it does |
|---|---|---|
| `GET` | `/api/v1/appointments` | List appointments (GHL calendar + tracked bookings + Google booking calendars) |
| `GET` | `/api/v1/calendar/calendars` | List Atribu booking calendars |
| `POST` | `/api/v1/calendar/calendars` | Create an Atribu booking calendar |
| `GET` | `/api/v1/calendar/calendars/{calendarId}/acl` | List shares on an Atribu booking calendar |
| `POST` | `/api/v1/calendar/calendars/{calendarId}/acl` | Share an Atribu booking calendar |
| `DELETE` | `/api/v1/calendar/calendars/{calendarId}/acl/{ruleId}` | Revoke a share on an Atribu booking calendar |
| `POST` | `/api/v1/calendar/events` | Create a calendar event |
| `PATCH` | `/api/v1/calendar/events/{id}` | Update a calendar event |
| `DELETE` | `/api/v1/calendar/events/{id}` | Delete (cancel) a calendar event |
| `GET` | `/api/v1/calendar/intents/{intentionId}` | Reconcile a booking intention |
| `POST` | `/api/v1/calendar/intents/{intentionId}` | Create a durable Calendar/Meet intention |
| `PATCH` | `/api/v1/calendar/intents/{intentionId}` | Update the same booking event |
| `DELETE` | `/api/v1/calendar/intents/{intentionId}` | Cancel the same booking event |
| `GET` | `/api/v1/calendar/read/calendars` | List external calendars (read-only) |
| `GET` | `/api/v1/calendar/read/events` | List external calendar events (read-only) |
| `GET` | `/api/v1/calendar/readiness` | Read current booking-calendar readiness evidence |
| `POST` | `/api/v1/comments/{comment_id}/private-reply` | Send a DM to the user who left an IG comment |
| `POST` | `/api/v1/comments/{comment_id}/reply` | Post a public reply on an IG comment thread |
| `GET` | `/api/v1/conversations` | List the unified inbox's conversations |
| `GET` | `/api/v1/conversations/{id}/detected-contacts` | List the contact data the customer typed in a conversation |
| `POST` | `/api/v1/conversations/{id}/draft` | Save a reply as a Gmail draft instead of sending it |
| `GET` | `/api/v1/conversations/{id}/labels` | List an email conversation's Gmail labels |
| `POST` | `/api/v1/conversations/{id}/labels` | Add/remove Gmail labels on an email conversation's thread |
| `POST` | `/api/v1/conversations/{id}/link-customer` | Link an Instagram conversation to a customer (human-driven DM→payment link) |
| `GET` | `/api/v1/conversations/{id}/messages` | Get a conversation's message thread |
| `POST` | `/api/v1/conversations/{id}/messages` | Send a reply on a conversation |
| `GET` | `/api/v1/conversations/{id}/organic-matches` | Get organic posts + ads related to a conversation |
| `GET` | `/api/v1/conversations/{id}/resolve-user` | Resolve an Instagram conversation's IGSID to @username/name/photo |
| `GET` | `/api/v1/conversations/{id}/review-proposals` | List a conversation's pending contact-extraction review proposals |
| `POST` | `/api/v1/conversations/{id}/review-proposals` | Approve or reject a pending contact-extraction review proposal |
| `POST` | `/api/v1/conversations/{id}/unlink-customer` | Undo a prior manual DM→payment link |
| `GET` | `/api/v1/dm-ads` | Messaging-cohort ad performance with conversation intent |
| `GET` | `/api/v1/dm-ads/board` | The DM Ads board — ranking, KPIs, funnel and creative patterns |
| `POST` | `/api/v1/email/drafts` | Create a draft (reply or new) |
| `GET` | `/api/v1/email/labels` | List a mailbox's labels |
| `POST` | `/api/v1/email/search` | Search threads in a connected mailbox |
| `GET` | `/api/v1/email/threads/{id}` | Get a normalized email thread |
| `POST` | `/api/v1/email/threads/{id}/labels` | Add/remove labels on a thread |
| `GET` | `/api/v1/inbox/pending-reviews-count` | Count the profile's pending review-queue proposals |
| `GET` | `/api/v1/instagram/accounts` | List connected Instagram accounts |
| `DELETE` | `/api/v1/instagram/accounts/{account_id}` | Disconnect an Instagram account |
| `GET` | `/api/v1/instagram/accounts/{account_id}/comment-triggers` | List an account's comment-to-DM triggers |
| `POST` | `/api/v1/instagram/accounts/{account_id}/comment-triggers` | Create a comment-to-DM trigger |
| `PATCH` | `/api/v1/instagram/accounts/{account_id}/comment-triggers/{trigger_id}` | Update a comment-to-DM trigger |
| `DELETE` | `/api/v1/instagram/accounts/{account_id}/comment-triggers/{trigger_id}` | Delete a comment-to-DM trigger |
| `POST` | `/api/v1/instagram/accounts/{account_id}/comment-triggers/{trigger_id}/test-dm` | Send a trigger's opening message as a test DM |
| `GET` | `/api/v1/instagram/accounts/{account_id}/media` | Page the account's synced posts |
| `POST` | `/api/v1/instagram/accounts/{account_id}/resume-comment-to-dm` | Clear a tripped comment-to-DM breaker |
| `PATCH` | `/api/v1/instagram/bio-media/{media_id}` | Show or hide one post on the hosted bio page |
| `GET` | `/api/v1/instagram/competitor-sources` | List tracked competitor handles and hashtags |
| `POST` | `/api/v1/instagram/competitor-sources` | Track a competitor handle or hashtag |
| `PATCH` | `/api/v1/instagram/competitor-sources/{id}` | Enable, disable or relabel a competitor source |
| `DELETE` | `/api/v1/instagram/competitor-sources/{id}` | Stop tracking a competitor source |
| `GET` | `/api/v1/instagram/competitor-sources/{id}/media` | Latest captured posts for one competitor source |
| `POST` | `/api/v1/instagram/competitor-sources/refresh` | Queue a competitor-research sync now |
| `GET` | `/api/v1/instagram/contacts/{igsid}` | Resolve an Instagram contact's profile |
| `GET` | `/api/v1/instagram/conversations` | List the connected Instagram account's conversations |
| `GET` | `/api/v1/instagram/conversations/{conversation_id}/messages` | Read the messages in an Instagram conversation |
| `GET` | `/api/v1/instagram/ctm-health` | Click-to-Message attribution health |
| `GET` | `/api/v1/instagram/ctm-inventory` | MESSAGES-objective ads and their ManyChat mapping |
| `GET` | `/api/v1/instagram/follower-attribution` | Which ads produced followers |
| `GET` | `/api/v1/instagram/identity-reviews` | Pending DM identity-match reviews |
| `POST` | `/api/v1/instagram/identity-reviews/{review_id}/decide` | Approve or reject a DM identity match |
| `GET` | `/api/v1/instagram/media` | List the connected Instagram account's media |
| `POST` | `/api/v1/instagram/media` | Create an Instagram media container (publish step 1) |
| `GET` | `/api/v1/instagram/media/{media_id}` | Read one Instagram media object |
| `POST` | `/api/v1/instagram/media/publish` | Publish an Instagram media container (publish step 2) |
| `GET` | `/api/v1/instagram/search-conversations` | Typeahead search over Instagram conversations |
| `GET` | `/api/v1/instagram/short-links` | List Instagram short links |
| `POST` | `/api/v1/instagram/short-links` | Mint an Instagram short link |
| `POST` | `/api/v1/instagram/short-links/follow-proxy` | Mint a Follow Proxy link for an Instagram handle |
| `POST` | `/api/v1/instagram/sync` | Queue an Instagram sync now |
| `GET` | `/api/v1/instagram/triggers` | List comment-to-DM triggers |
| `POST` | `/api/v1/instagram/triggers` | Create a comment-to-DM trigger |
| `PATCH` | `/api/v1/instagram/triggers/{id}` | Update a comment-to-DM trigger |
| `DELETE` | `/api/v1/instagram/triggers/{id}` | Delete a comment-to-DM trigger |
| `POST` | `/api/v1/instagram/triggers/{id}/test-dm` | Send the trigger's opening_message as a DM to a test IGSID |
| `POST` | `/api/v1/instagram/triggers/resume` | Manually clear a tripped comment-to-DM circuit |
| `GET` | `/api/v1/instagram/unlinked-payments` | List recent cash payments with no linked Instagram conversation |
| `POST` | `/api/v1/messages` | Send a channel-agnostic message |
| `POST` | `/api/v1/messages/typing` | Mark a WhatsApp message read (with or without a typing indicator) |
| `GET` | `/api/v1/organic/posts` | List organic posts with attribution |
| `GET` | `/api/v1/organic/posts/{id}` | Get one organic post's attribution detail |
| `GET` | `/api/v1/organic/posts/{id}/intelligence` | Get one organic post's AI analysis and twin ads |
| `GET` | `/api/v1/organic/stories/{id}` | Get one Instagram story's attribution detail |
| `GET` | `/api/v1/tiktok/conversations` | List the connected TikTok account's conversations |
| `GET` | `/api/v1/tiktok/conversations/{conversation_id}/messages` | Read the messages in a TikTok conversation |
| `GET` | `/api/v1/whatsapp/account-health` | Get WhatsApp channel health |
| `GET` | `/api/v1/whatsapp/automatic-events` | Read the Automatic Events opt-in state for this profile's WABAs |
| `PATCH` | `/api/v1/whatsapp/automatic-events` | Record the Automatic Events opt-in confirmation |
| `GET` | `/api/v1/whatsapp/broadcasts` | List broadcasts |
| `POST` | `/api/v1/whatsapp/broadcasts` | Create a broadcast |
| `GET` | `/api/v1/whatsapp/broadcasts/{id}` | Get a broadcast + recipients |
| `PATCH` | `/api/v1/whatsapp/broadcasts/{id}` | Cancel a broadcast |
| `POST` | `/api/v1/whatsapp/broadcasts/{id}/send` | Start sending a broadcast |
| `GET` | `/api/v1/whatsapp/calling` | Read a number's calling settings |
| `POST` | `/api/v1/whatsapp/calling` | Enable or update a number's calling settings |
| `GET` | `/api/v1/whatsapp/calling/sip-credentials` | Read a number's SIP digest credentials |
| `GET` | `/api/v1/whatsapp/conversations/{id}/messages` | List messages in a WhatsApp conversation |
| `POST` | `/api/v1/whatsapp/conversations/{id}/messages` | Send a text or media message into a WhatsApp conversation |
| `POST` | `/api/v1/whatsapp/conversations/{id}/messages/declared-source-prompt` | Ask a WhatsApp customer "how did you find us?" |
| `POST` | `/api/v1/whatsapp/conversations/{id}/messages/interactive` | Send an interactive button message into a WhatsApp conversation |
| `POST` | `/api/v1/whatsapp/conversations/{id}/messages/template` | Send a template message into a WhatsApp conversation |
| `GET` | `/api/v1/whatsapp/declared-source-prompt` | Read the "how did you find us?" prompt opt-in and the spec a bot follows |
| `PATCH` | `/api/v1/whatsapp/declared-source-prompt` | Turn the "how did you find us?" prompt on or off |
| `GET` | `/api/v1/whatsapp/flows` | List WhatsApp flows |
| `POST` | `/api/v1/whatsapp/flows` | Create a WhatsApp flow |
| `GET` | `/api/v1/whatsapp/flows/{flowId}` | Read a WhatsApp flow |
| `POST` | `/api/v1/whatsapp/flows/{flowId}` | Update a WhatsApp flow's metadata |
| `DELETE` | `/api/v1/whatsapp/flows/{flowId}` | Delete a WhatsApp flow (DRAFT only) |
| `GET` | `/api/v1/whatsapp/flows/{flowId}/assets` | List a WhatsApp flow's assets |
| `POST` | `/api/v1/whatsapp/flows/{flowId}/assets` | Upload a WhatsApp flow's JSON |
| `POST` | `/api/v1/whatsapp/flows/{flowId}/deprecate` | Deprecate a WhatsApp flow |
| `POST` | `/api/v1/whatsapp/flows/{flowId}/publish` | Publish a WhatsApp flow |
| `GET` | `/api/v1/whatsapp/flows/endpoint` | Read the WhatsApp Flows data endpoint |
| `PUT` | `/api/v1/whatsapp/flows/endpoint` | Register the WhatsApp Flows data endpoint |
| `POST` | `/api/v1/whatsapp/flows/endpoint/rotate-secret` | Rotate the WhatsApp Flows data-hop signing secret |
| `POST` | `/api/v1/whatsapp/label-mappings` | Map a GoHighLevel pipeline stage to a WhatsApp label |
| `DELETE` | `/api/v1/whatsapp/label-mappings/{id}` | Remove a pipeline-stage → WhatsApp-label mapping |
| `GET` | `/api/v1/whatsapp/labels` | Read this profile's WhatsApp labels and pipeline-stage mappings |
| `POST` | `/api/v1/whatsapp/labels` | Create a WhatsApp label on this profile's WABA |
| `POST` | `/api/v1/whatsapp/media` | Upload WhatsApp media (pre-upload → media_id) |
| `GET` | `/api/v1/whatsapp/media/{mediaId}` | Resolve WhatsApp media (media_id → hosted URL) |
| `GET` | `/api/v1/whatsapp/otp-capture` | Read back a published OTP capture |
| `POST` | `/api/v1/whatsapp/otp-capture` | Publish an OTP captured off Meta's verification call |
| `GET` | `/api/v1/whatsapp/registration/funding` | Read a WABA's funding + status (payment gate) |
| `GET` | `/api/v1/whatsapp/registration/phone-numbers` | List the phone numbers on a WABA |
| `POST` | `/api/v1/whatsapp/registration/phone-numbers` | Add (or migrate) a phone number onto a WABA |
| `POST` | `/api/v1/whatsapp/registration/register` | Register the number + subscribe Atribu's app |
| `POST` | `/api/v1/whatsapp/registration/request-code` | Request the registration OTP (voice) |
| `POST` | `/api/v1/whatsapp/registration/subscribe` | Subscribe Atribu's app to an already-registered WABA |
| `GET` | `/api/v1/whatsapp/registration/subscribed-apps` | List apps subscribed to a WABA |
| `POST` | `/api/v1/whatsapp/registration/verify-code` | Verify the registration OTP |
| `GET` | `/api/v1/whatsapp/templates` | List WhatsApp message templates |
| `POST` | `/api/v1/whatsapp/templates` | Create a WhatsApp message template |
| `DELETE` | `/api/v1/whatsapp/templates/{name}` | Delete a WhatsApp message template by name |
| `GET` | `/api/v1/whatsapp/templates/{name}/analytics` | Meta analytics for one WhatsApp template |
| `POST` | `/api/v1/whatsapp/templates/sync` | Sync WhatsApp message templates from Meta |

Generated from `openapi.json`. The full request and response schema for every operation is in the [OpenAPI document](https://www.atribu.app/api/v1/openapi.json).

{/* END GENERATED ENDPOINT TABLE */}

## Next steps

<Cards>
  <Card title="Node SDK" href="/docs/sdks/node">
    Payload shapes, send semantics, webhook verification
  </Card>
  <Card title="Webhooks" href="/docs/api/webhooks">
    Subscribing to inbound messages and comments
  </Card>
  <Card title="Partners & OAuth apps" href="/docs/api/partners">
    Becoming a consumer of these connections
  </Card>
</Cards>

---

# Commerce

Two reads for a consumer application working on a connected merchant's behalf:
the product catalogue, and the "where is my order" lookup. Both require
`commerce:read`.

## Products

```http title="Endpoint"
GET /api/v1/commerce/products
```

The catalogue with each product's variants nested, **keyset-paginated on
`(updated_at, product_id)` ascending**.

<Callout type="info" title="A webhook is a tickle, not the data">
`catalog.updated` tells you a pull is worth making; it never carries the
catalogue. Pass the newest `updated_at` you have seen as `updated_since` and
walk forward — the ascending order is what keeps a cursor valid no matter how
many products change while you page.
</Callout>

## Orders

```http title="Endpoint"
GET /api/v1/commerce/orders
```

**At least one of `order_ref`, `email` or `phone` is required.** A call with
none answers `400`: this is a lookup on a named shopper's behalf, not a dump of
the merchant's order history.

**No customer identity comes back.** You already hold whichever contact you
searched with, so echoing it would tell you nothing — and not returning it is
what keeps the route from becoming an enumeration tool.

## Live store reads

```http title="Endpoints"
GET /api/v1/commerce/live/products
GET /api/v1/commerce/live/orders
```

The two reads above serve **Atribu's copy** of the store. These two read the
connected Shopify store **live**, from Shopify's Admin API, in the request:
use them for an initial import and a periodic reconcile, and take deltas from
the `shopify` webhook events. Both require `commerce:live`, which the
`commerce` connect scope grants together with `commerce:read`.

- **Products** carry every variant, read to the end — `variant_id` (the id a
  cart permalink takes), `options`, `price`, `compare_at_price`,
  `inventory_quantity`, `inventory_policy`, `tracked` — in every status
  (`active`, `archived`, `draft`).
- **Orders** come in every status, with `financial_status` and
  `fulfillment_status` in Shopify's REST spelling (the values the `orders/*`
  webhooks carry), `attributes` (the cart / note attributes, e.g.
  `vitrina_order`), and every line with its `variant_id` and `quantity`.

Both sort by Shopify's `updated_at` ascending. Pass `updated_since` to read a
delta and walk `pagination.cursor` until `has_next` is false — the cursor
carries the filter and the store, so send it alone. `meta.connection_id` and
`meta.shop` name the store; a profile with several stores needs
`connection_id`. A throttled Shopify response waits for its cost budget and
retries rather than failing the page.

<Callout type="warn" title="Personal data">
Every order from `GET /api/v1/commerce/live/orders` carries the buyer's
`email` and `phone`. That is why `commerce:live` is its own scope.
</Callout>

| Status | Code | When |
|---|---|---|
| `404` | `not_found` | The profile has no live Shopify connection |
| `400` | `invalid_parameter` | Bad `updated_since`; several stores and no `connection_id` |
| `409` | `connection_revoked` | Shopify no longer accepts the store's grant — reconnect it |
| `502` | `provider_error` | Shopify failed the read (its own message) |
| `503` | `service_unavailable` | The token could not be refreshed right now; retry |

{/* BEGIN GENERATED ENDPOINT TABLE — generated by scripts/docs/generate-api-docs.ts from openapi.json. Do not hand-edit between these markers; run `npx tsx scripts/docs/generate-api-docs.ts`. */}

| Method | Path | What it does |
|---|---|---|
| `GET` | `/api/v1/commerce/live/orders` | Read a connected store's orders live |
| `GET` | `/api/v1/commerce/live/products` | Read a connected store's catalogue live |
| `GET` | `/api/v1/commerce/orders` | Look an order up |
| `GET` | `/api/v1/commerce/products` | List commerce products |

Generated from `openapi.json`. The full request and response schema for every operation is in the [OpenAPI document](https://www.atribu.app/api/v1/openapi.json).

{/* END GENERATED ENDPOINT TABLE */}

## Next steps

<Cards>
  <Card title="Shopify" href="/docs/integrations/shopify">
    The connection these reads sit behind
  </Card>
  <Card title="Webhooks" href="/docs/api/webhooks">
    `catalog.updated`, `order.status.changed` and the rest
  </Card>
</Cards>

---

# Partners & OAuth Apps

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](/docs/api/credentials) and the
[API quickstart](/docs/api/quickstart) instead.

## The credential

```http title="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](https://datatracker.ietf.org/doc/html/rfc7009)
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](/docs/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 scope | Mints | Reaches |
|---|---|---|
| `connections` | `connections:read`, `connections:write` | The 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. |
| `attribution` | Every 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:write` | Analytics 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. |
| `analytics` | `analytics:read`, `campaigns:read`, `conversions:read`, `realtime:read` | Read-only analytics, no PII. Still a valid request for an app that wants a narrower grant than `attribution`. |
| `analytics_pii` | The `analytics` set, plus `customers:read`, `visitors:read` | Adds the two PII reads. Still valid, granularly. |
| `attribution_write` | `goals:write`, `attribution:write`, `tracking:write`, `reports:write` | The 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.

| Tier | What it reaches | How you get it |
|---|---|---|
| **Connect broker** | Channel 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-on** | Everything: 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:

```http title="Endpoint"
PUT /api/v1/profiles/{profileId}/entitlements/atribu_attribution
```

```json title="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.

<Callout type="info" title="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).
</Callout>

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

| Response | What it means | What 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_required` | The Add-on isn't active for this profile at all | No 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.

```json title="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](#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.

<Callout type="info" title="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.
</Callout>

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

```http title="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.

<Callout type="warn" title="A partner-provisioned profile is deliberately not attribution-entitled">
Such a profile answers `blocked` on [readiness](/docs/api/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.
</Callout>

## Archiving a profile you provisioned

```http title="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}`](/docs/api/workspaces) 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.

```json title="Response"
{
  "data": {
    "profile_id": "…",
    "workspace_id": "…",
    "archived": true,
    "workspace_archived": true
  }
}
```

<Callout type="warn" title="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.
</Callout>

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

## Naming the profile a consent lands on

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:

```ts title="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_description` | What happened | What to do |
|---|---|---|
| `external_ref_required` | The hint carried no `external_ref` claim (or a blank one) | Sign the hint with `externalRef`. |
| `unknown_external_ref` | No live profile of yours is provisioned under that ref | `POST /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.

<Callout type="warn" title="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`.
</Callout>

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)

```http title="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.

```json title="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.

<Callout type="info" title="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](https://www.atribu.app/api/v1/openapi.json) and its
generated types.
</Callout>

## Declaring the attribution entitlement (#1418)

```http title="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.

```json title="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`](/docs/api/connections).

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

<Callout type="info" title="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.
</Callout>

```json title="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)

```http title="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.

<Callout type="warn" title="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.
</Callout>

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

```json title="PUT — grant a guest two profiles"
{
  "email": "ops@merchant.example",
  "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`.

<Callout type="warn" title="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`.
</Callout>

`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

```http title="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.

<Callout type="warn" title="Your own app-provisioned profiles only">
The check is the same stamp [provisioning](#provisioning-a-merchant) 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.
</Callout>

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](/docs/api/connections).

## Registering the app

```http title="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](/docs/api/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.

```json title="POST /api/v1/profiles"
{
  "external_ref": "clinic_4471",
  "name": "Clínica Dental Norte",
  "email": "owner@clinicanorte.cl",
  "privacy_mode": "platform_safe"
}
```

```json title="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](/docs/api/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:

```json
{
  "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 response | Fields |
|---|---|
| The person | name, email, phone, `customer_profile_id` |
| Pages | `url`, `path`, `referrer_domain`, `event_payload` |
| Device and geo | `device`, `browser`, `os`, `country`, `city` |
| Browser identity | `session_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](/docs/api/customers#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](/docs/api/customers#pages-your-app-renders-partner_page).

## 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:
no URL, 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.

**No URL at all (#2223).** A Platform-safe profile sends no `event_source_url`
(and no TikTok `page.url`), not even the domain, on every event, website events
included. Meta categorizes a dataset by the site its data comes from, so a
clinic's funnel domain alone can get a CAPI-only dataset restricted, health word
or not. It is a floor: `PUT /api/v1/conversion-sync/catalog` accepts any
`privacy_config.url_sanitization` in this mode and stores `omit`. Meta lists
`event_source_url` as required for website events but accepts events without
it; matching relies on `user_data` (hashed email, phone, external id) plus
`fbc` (never `fbp`, see below).

**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`.

**No `fbp` (browser id).** A Platform-safe payload never carries `user_data.fbp`,
from any source: the tracker's own event, a sibling browser event, the
customer's stored browser signals, or the visitor `_fbp` a GoHighLevel lead
carries. `_fbp` is set by a Meta Pixel on some domain, and Meta already holds
that id together with the pages that Pixel fired on, so sending it would link
the scrubbed events back to browser activity on the original (possibly
restricted) site. A Platform-safe setup sends through the Conversions API only,
with no Pixel, so `fbp` adds almost no match value there. `fbc` (from the ad
click), hashed email/phone/name, `external_id`, IP and user agent are kept. It
is fixed in this mode: no stored config or rule override turns it back on. The
delivery's filters read `fbp_removed`.

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. Move them the
way every ad set is connected, in Conectar campañas:
`GET /api/v1/conversion-sync/wiring/campaigns` offers ad sets on any pixel of the
ad account (the Source dataset, which may be the merchant's own pixel) and
`POST /api/v1/conversion-sync/wiring/ad-sets` recreates them on the Clean
Dataset as one background operation. The dedicated
`platform-safe/repoint` and `platform-safe/go-live` endpoints were deleted on
2026-10-23 (#2208) and answer `404`. 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`:

| Answer | Meaning |
|---|---|
| `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_required` | Step 1 was skipped. |
| `409 meta_connection_required` | No 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](/docs/api/legal#the-dpa-for-a-partner-app)). 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.

## Automated duplicate-and-swap, behind your consent screen (#306)

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

| Route | Confirmation flag | Applies to |
|---|---|---|
| `POST /api/v1/conversion-sync/wiring/ad-sets` | `confirm_duplicate_swap: true` + `pause_original` | Any profile: points ad sets at a rule's conversion event, from any Source dataset — onto a Platform-Safe profile's Clean Dataset too. |

The rebuilds run as one background operation you can follow on
`GET /api/v1/conversion-sync/operations/{id}`. (`platform-safe/repoint`, the
second route this section used to list, and its successor `platform-safe/go-live`
were deleted on 2026-10-23, #2208.)

It works in two rounds. The first call (no confirmation) edits what Meta
allows and hands back the frozen ad sets in `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, and the copy goes live.

**What happens to the original is asked per ad set (#2207).** Send
`pause_original: { "<ad_set_id>": true | false }` with an answer for every ad
set that will be recreated: `true` pauses the original last, once its copy is
live (never deleted); `false` leaves it running beside the copy, which doubles
the spend on that audience and puts both in the same auction. There is no
default: a rebuild without an answer is `400 validation_error` naming the ad
sets, with nothing queued. In-place edits need no answer.

**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:

```json title="POST /api/v1/conversion-sync/wiring/ad-sets"
{
  "rule_id": "22222222-2222-4222-8222-222222222222",
  "destination_id": "11111111-1111-4111-8111-111111111111",
  "ad_set_ids": ["120200000000000002"],
  "confirm_duplicate_swap": true,
  "pause_original": { "120200000000000002": 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.

```text title="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`

```text title="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.

| Answer | Meaning |
|---|---|
| `409 consent_required` | The 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_error` | `consent` 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. |
| `200` | Per-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:

| Code | Meaning |
|---|---|
| `new_ad_set_ids` | Every rebuilt ad set now runs under a new id; its reporting starts from zero. Update any id you store. |
| `learning_reset` | Meta's learning phase restarted on each copy (about 50 conversions). |
| `double_spend_window` | The copy went live before the original was paused, so both could deliver briefly. The original is now paused. |
| `double_spend_risk` | Meta refused to pause the original: **both are spending now**. The merchant must pause the original id in Ads Manager. |
| `budget_doubled` | `pause_original: false` 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. 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`](#journey-reads-without-customersread),
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:

| `code` | `reason` | when |
|---|---|---|
| `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_required` | the Meta token is dead (`reconnect_required: true`) |
| `forbidden` (403) | `meta_permission_denied` / `applied_by_not_allowed` | Meta refused for a missing permission / your `applied_by` is not an active owner or admin |
| `rate_limit_exceeded` (429) | `meta_rate_limited` | Meta throttled the ad account (`Retry-After`) |
| `provider_error` (502) / `invalid_request` (422) | `meta_refused` | any 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**.

| Endpoint | Marker | What is unknown while it is set |
|---|---|---|
| `GET /api/v1/connections`, `GET /api/v1/connections/{id}` | `spend_unavailable: true` | `delivers_spend` is `null` — *unknown*, not *not applicable* |
| `GET /api/v1/conversations` | `enrichment_unavailable: true` | `ad_name`, `campaign_name` are `null`; `linked_customer_is_payer` used email/phone only |
| `GET /api/v1/profile`, `PATCH /api/v1/profile` | `analytics: "unavailable"` | `has_subscription_revenue` is its default, `false` |
| `GET /api/v1/profile/readiness` | `analytics: "unavailable"` + per-step `evidence` | every step with `evidence: "unavailable"` |
| `POST /api/v1/partner/wa-joins` | `attribution_deferred: true` | only 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 outcome | `action_source` | Also send |
|---|---|---|
| Born in a WhatsApp / Messenger / Instagram conversation (a booking, a quote accepted, a sale closed in the chat) | `business_messaging` | `click_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_store` | email/phone |
| A fact your system derived (appointment confirmed, status changed, a scheduled job) | `system_generated` | email/phone |
| Something the buyer did on your website (a form the tag saw, a checkout) | `website` | `anonymous_id`, `click_ids.fbclid` |
| Phone call, email, your own app | `phone_call`, `email`, `app` | email/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 pixel as `system_generated`: Meta
  rejects a `business_messaging` event without a messaging click id (400,
  subcode 2804071 — and the whole batch with it). The delivery's filters then
  read `action_source_degraded:business_messaging_to_system_generated`. No event
  without a `ctwa_clid` is ever sent as `business_messaging` or `chat`; a
  declared `chat` is exported as `system_generated` too. If the payment happened
  at the business's counter, declare `physical_store` yourself — Atribu cannot
  infer it.
- **Without a click id on a profile with no website pixel** (only the WhatsApp
  dataset is wired), the event is **not sent**. It is never routed to the
  WhatsApp dataset. The export ledger records it as `skipped` with
  `skip_reason: no_website_destination`, so `GET /api/v1/exports/ledger` shows
  every such event rather than none.

How `system_generated` is refined. It is the declaration that does not say where
the outcome happened, so Atribu checks its own evidence before exporting it:

- **Atribu's tag captured the same event on the site** (the outcome's
  `canonical_event_id` is the browser event's id, e.g. a quoter's Pixel
  `eventID`), it is a website event: it goes to the website pixel as `website`,
  with the page URL as `event_source_url` (no URL on a `platform_safe` or
  `hipaa` profile) and the same `event_id`, so Meta deduplicates it against the
  browser event. The delivery's filters read
  `action_source_refined:system_generated_to_website`.
- **No web footprint, and the buyer came from a click-to-WhatsApp ad** (a
  `ctwa_clid` on the customer) on a profile with a WhatsApp dataset, it was born
  in the conversation: it goes to the WhatsApp dataset ONLY, as
  `business_messaging` with that click id, never to the website pixel. Filters:
  `action_source_refined:system_generated_to_business_messaging`.
- Otherwise it is exported to the website pixel as `system_generated`, unchanged.

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.

## The WhatsApp dataset: status and the create button

A click-to-WhatsApp conversion is credited by Meta only when it reaches the
dataset linked to the business's WhatsApp Business Account. For a profile
**your app provisioned**, Atribu never creates that dataset on its own:
connecting WhatsApp and Meta Ads only reports it `ready`. Your user creates it
with one call — typically behind a button in your UI.

### `GET /api/v1/conversion-sync/data-sources` (`exports:read`)

```json
{
  "data": {
    "creation": "on_request",
    "prerequisites": {
      "meta_ads": { "connected": true, "connection_id": "…", "ad_account_id": "act_123" },
      "whatsapp": { "connected": true, "waba_id": "513624665170782" },
      "instagram": { "connected": false, "ig_account_id": null },
      "attribution_entitlement": "entitled"
    },
    "data_sources": [
      {
        "kind": "whatsapp", "state": "ready", "reason_code": null,
        "reason": "Meta Ads and WhatsApp are connected; the WhatsApp dataset can be created.",
        "asset_id": "513624665170782", "dataset_id": null, "destination_id": null,
        "destination_enabled": null, "export_from": null, "access_granted": null,
        "can_ensure": true, "shared_with_profiles": [], "warning": null
      },
      {
        "kind": "website", "state": "not_configured", "reason_code": "no_website_destination",
        "reason": "No website pixel is wired as a Conversions API destination. …",
        "asset_id": "act_123", "dataset_id": null, "destination_id": null,
        "destination_enabled": null, "export_from": null, "access_granted": null,
        "can_ensure": false, "shared_with_profiles": [], "warning": null
      }
    ],
    "export_preview": {
      "window_days": 30, "enabled_meta_rules": 0, "enabled_meta_stage_rules": 0,
      "events": { "total": 1039, "whatsapp_lane": 596, "website_lane": 443 },
      "rule_matched": { "total": 0, "whatsapp_lane": 0, "website_lane": 0 },
      "whatsapp_lane": { "destination": "whatsapp_dataset", "action_source": "business_messaging" },
      "website_lane": { "destination": null, "not_sent_reason": "no_website_destination" },
      "truncated": false, "sampled_events": 1039
    }
  },
  "meta": { "profile_id": "…" }
}
```

- `creation` — `on_request` for a profile an app provisioned (only the call
  below creates the dataset); `automatic` for a profile created in Atribu (it is
  created the moment both connections exist).
- `state` — `active` (an enabled destination exists), `ready` (`can_ensure`
  is true), `blocked` (`reason_code`: `meta_ads_not_connected`,
  `whatsapp_not_connected`, `instagram_not_connected`, `duplicate_kind`,
  `entitlement_required`), or
  `not_configured` (the website pixel; this surface never creates one).
  `destination_disabled` with `state: ready` means the call below re-enables it.
- `shared` / `shared_count` / `shared_with_profiles` / `warning` — whether
  OTHER Atribu profiles already hold an enabled Meta destination on the same ad
  account or dataset (`warning: "meta_destination_shared"`): the same people may
  then reach Meta twice. `shared_with_profiles` names only profiles in **your
  own workspace**; one in another workspace is counted, never named. Report
  only; it never blocks the ensure. `shared: null` when it could not be checked.
- `selectable_pixels` — the pixels on the connected ad account (`id`, `name`,
  `last_fired_time`), read live from Meta: what `website/ensure` accepts. `null`
  (never `[]`) when Meta could not be read, with `selectable_pixels_error`.
- `export_preview.website_lane.by_action_source` — the window's website-lane
  events by the `action_source` they would carry (declared value verbatim; a
  click-less `business_messaging` → `system_generated`; undeclared →
  `inferred`).
- `export_from` — the activation watermark. Nothing that happened before it is
  ever exported: turning the dataset on never ships history.
- `export_preview` — the last 30 days, split by the lane each event would take:
  with a click-to-WhatsApp click id → the WhatsApp dataset as
  `business_messaging`; everything else → the website pixel only when one
  exists, otherwise not sent. `rule_matched` is the subset an enabled Meta
  signal rule matches; when `enabled_meta_rules` is `0` **nothing is exported
  at all**, whatever the destinations say.

Never calls Meta.

### `POST /api/v1/conversion-sync/data-sources/whatsapp/ensure` (`exports:write`)

No body. Creates (or fetches) the dataset linked to the WhatsApp Business
Account, grants Atribu's system user access to it, and wires it as an enabled
Conversions API destination whose `export_from` is now. Idempotent: `201` when
this call created the destination, `200` when it already existed, so a retry is
always safe.

```json
{ "data": { "outcome": "created", "data_source": { "kind": "whatsapp", "state": "active", "dataset_id": "1497811675116864", "…": "…" } } }
```

| Status / code | Meaning |
|---|---|
| `400 invalid_parameter` | `kind` is not `whatsapp`, `website` or `instagram`. |
| `403 forbidden` | A delegated key of an app other than the one that provisioned the profile, or a person who is not a workspace owner/admin. |
| `409 meta_connection_required` | No Meta Ads connection. |
| `409 whatsapp_connection_required` | No live WhatsApp Business Account. |
| `409 invalid_state` (`reason: duplicate_kind`) | Another enabled WhatsApp destination already holds this ad account. |
| `409 invalid_state` (`reason: dataset_unreadable` / `already_exists`) | Meta has a dataset this token cannot read; the business must grant access in Business Settings. |
| `409 entitlement_required` | The profile does not hold `atribu_attribution`. |
| `401` (`reconnect_required`) · `403 forbidden` · `502 provider_error` | Meta refused or failed; `reason` carries Meta's classified code. |

### `POST /api/v1/conversion-sync/data-sources/website/ensure` (`exports:write`)

Attaches one of the connected ad account's **existing** pixels as the website
destination, so events without a click-to-WhatsApp click id (payments above
all) reach Meta. Body: `{ "dataset_id": "<pixel id from selectable_pixels>" }`.
The id is checked against Meta's own list of the account's pixels; **Atribu
never creates a pixel here**. `export_from` is set to the moment of the call.
`201` when attached, `200` when that pixel was already attached; a different
pixel while one is attached is `409 invalid_state` (`reason: duplicate_kind`).
`422 validation_error` for a missing `dataset_id` or one not on the account.
Same scope, operator and own-app rules as the WhatsApp ensure.

### `POST /api/v1/conversion-sync/data-sources/instagram/ensure` (`exports:write`)

No body. Exactly like the WhatsApp ensure, on the profile's connected Instagram
professional account: creates (or fetches) the dataset linked to it
(`POST /{ig_user_id}/dataset` at Meta), grants Atribu's system user access, and
wires it as an enabled Conversions API destination whose `export_from` is now.
`201` created / `200` already present. `409 instagram_connection_required` when
no Instagram account is connected; one Instagram dataset per ad account
(`409 invalid_state`, `reason: duplicate_kind`). Meta creates Instagram
datasets only for an account connected through **Facebook Login for Business**
(linked to a Facebook Page): an account connected through Instagram Login is
refused with `409 instagram_facebook_login_required` before any Meta call, and
the status reports it first as that `reason_code`. The status above reports it as
the `instagram` entry of `data_sources` (`reason_code: instagram_not_connected`
when blocked) and in `prerequisites.instagram`. Meta only creates it for an app
with advanced `instagram_manage_events` access; otherwise the call answers with
Meta's classified refusal and nothing is written.

Once it exists, an outcome born in an Instagram-ad thread is exported to it as
`business_messaging` when its `click_ids` carry all three of
`messaging_channel: "instagram"`, `ig_sid` (the customer's Instagram-scoped id)
and `instagram_business_account_id` (send `action_source: "business_messaging"`
too). The account id must name the dataset's Instagram account, by its
`ig_user_id` or its `ig_business_account_id` (an event for another account goes
to the website pixel, ledgered `instagram_account_mismatch`); Meta always
receives the account's `ig_user_id`. Atribu maps those names to Meta's
Instagram `user_data`. Without an
Instagram dataset the same event goes to the website pixel as
`system_generated`, with the Instagram fields dropped, and its ledger row says
`instagram_destination_missing`; it is never sent to both. Meta does not
deduplicate business-messaging events, so send each outcome once, under a stable
`idempotency_key`.

In `platform_safe` mode with `platform_safe_target: "existing"` (the default)
the attached pixel is a target and every payload still passes the Platform-Safe
cleanse. With `target: "clean"` ordinary pixels are suppressed and the Clean
Dataset (`POST /api/v1/conversion-sync/platform-safe/provision`) is what
receives website-lane events.

## `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`.

```json
{
  "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:

```json
{
  "waba_id": "1435251738018078",
  "customer": {
    "external_ref": "vitrina-patient-a91d",
    "email": "paciente@example.cl",
    "phone": "+56944444444"
  },
  "answer_key": "instagram",
  "answered_at": "2026-09-20T18:42:05-03:00",
  "wamid": "wamid.HBgLNTY5NDQ0NDQ0NDQVAgASGBQ3RkQ2QzE="
}
```

```json
{
  "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.

```json
{
  "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.

```json
{
  "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.

```json
{
  "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.

```json
{
  "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"
    }
  ]
}
```

```json
{
  "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.

## The customer-list audience: `/api/v1/partner/audiences/customer-list`

A Meta customer list of the people who **paid** a Platform-Safe profile in the
last 365 days, refreshed every night, and its 1 % lookalike. It is the only
audience a Platform-Safe profile can have: every other audience (value-based
seeds, their lookalikes, exclusion and retargeting lists) answers
`409 platform_safe_audiences_disabled`.

**Off until Atribu enables it.** Every profile starts with
`available: false, reason: "not_enabled"`. Read `GET` first: it is the
capability check, and its `reason` says what is missing (`not_enabled`,
`not_platform_safe_eligible`, `dpa_not_accepted`, `meta_write_not_granted`,
`meta_connection_ambiguous`, `no_ad_account`). `exports:read` reads it;
`exports:write` creates, extends and deletes it.

**What reaches Meta.** The columns the consent the list was recorded under
covers. Under the current text (`2026-10-04.customer-list.v2`): hashed e-mail,
phone, first name, last name and country, each from the member's own customer
record (when the payer is a guardian, the guardian's), with country taken from
their own phone number. No city, region or postal code is sent, because the
customer record holds none. A list created under `2026-09-30.customer-list.v1`
keeps sending hashed e-mail and phone only. Never a national id, an amount or a
value (`customer_file_source: USER_PROVIDED_ONLY`,
`subtype: CUSTOM`). Members are customers whose payments in the last 365 days,
net of refunds and disputes, are positive; a duplicate merged into a customer
counts as that customer. There is no fallback to leads or appointments. People who opted out
of marketing (an event sent with `consent.marketing: false`), erased customers,
merged duplicates and customers your app marked (below) are left out. The names are fixed by Atribu
(«Vitrina · Clientes que pagaron», «Vitrina · Parecidos a clientes que pagaron (1 %)»)
and never taken from your request.

**Consent.** `POST` must carry
`consent: { version, text_hash, accepted_by, accepted_at }` for the current
customer-list consent text (`CUSTOMER_LIST_AUDIENCE_CONSENT_VERSION` /
`CUSTOMER_LIST_AUDIENCE_CONSENT_TEXT` in `@atribu/api-contract`), rendered
verbatim and accepted by the business owner. Anything else is
`409 consent_required`, before any Meta call. A new list needs the current
version. A list already live under v1 keeps uploading only what v1 covers, and a
`POST` with its original v1 acceptance stays an idempotent `202`. `POST` with the
v2 consent re-records it on the same list (nothing is re-created).

```bash
curl -X POST https://www.atribu.app/api/v1/partner/audiences/customer-list \
  -H "Authorization: Bearer atb_live_..." -H "Content-Type: application/json" \
  -H "Idempotency-Key: clinic-42-optin-1" \
  -d '{"consent":{"version":"2026-10-04.customer-list.v2","text_hash":"b83f00f0…","accepted_by":"owner-17","accepted_at":"2026-09-30T18:00:00Z"},"lookalike":{"ratio":0.01,"country":"CL"}}'
```

It answers `202` with the state. A second `POST` returns the same audience
without creating or uploading again. Below about 100 people the lookalike is
`waiting_size` and is created by the nightly refresh once the list grows;
`POST …/customer-list/lookalike` adds it later. `DELETE` deletes the
lookalike and the list **in Meta** and stops the refresh. It works even after
the feature was switched off.

**Keeping customers out: `PUT /api/v1/profiles/{profileId}/audience-exclusions`.**
Your app can mark customers that must never be in an audience. The mark is
generic: Atribu attaches no meaning to it and infers nothing. Who carries it
is your decision, and a customer you do not mark is not excluded. App credential
(Basic `client_id:client_secret`), profiles your app provisioned only. Each row
names a customer like the routes above and states the end state:

```json
{ "customers": [ { "customer_key": "c-8841", "excluded": true },
                 { "email": "persona@example.com", "excluded": false } ] }
```

Idempotent (replay changes nothing; the last row for a customer wins), up to
1,000 rows per call. A row that names nobody answers `not_found` and creates
nothing, so sync your marks again after new customers reach Atribu, and before
the audience is first enabled. A `503`
[`database_unavailable`](/docs/api/errors#database_unavailable) means Atribu's
database could not be reached mid-request: retry the same body after
`Retry-After` seconds. A partly applied batch converges, because the call is
idempotent.

## `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`:

| value | meaning | what to do |
|---|---|---|
| `present` | the ad has a greeting, in `greeting` | use it |
| `none` | the creative was read and configures none | nothing — a normal ad, and a final answer |
| `unreadable` | Meta 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.

{/* BEGIN GENERATED ENDPOINT TABLE — generated by scripts/docs/generate-api-docs.ts from openapi.json. Do not hand-edit between these markers; run `npx tsx scripts/docs/generate-api-docs.ts`. */}

| Method | Path | What it does |
|---|---|---|
| `POST` | `/api/v1/admin/oauth-apps` | Create 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-secret` | Rotate the OAuth app's `client_secret` |
| `POST` | `/api/v1/admin/oauth-apps/{id}/rotate-jwt-secret` | Rotate the OAuth app's `id_token_hint` HS256 signing secret |
| `GET` | `/api/v1/admin/oauth-apps/{id}/usage` | One consumer app's API usage |
| `GET` | `/api/v1/admin/pii-grants` | Who can read personal data through this API |
| `GET` | `/api/v1/admin/shopify-apps` | List registered Shopify apps |
| `POST` | `/api/v1/admin/shopify-apps` | Register 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-referrals` | Record a historical click-to-message ad referral |
| `POST` | `/api/v1/partner/ad-referrals/preview` | Preview what a batch of historical ad referrals would re-credit |
| `GET` | `/api/v1/partner/audiences/customer-list` | Read the customer-list audience |
| `POST` | `/api/v1/partner/audiences/customer-list` | Create the customer-list audience |
| `DELETE` | `/api/v1/partner/audiences/customer-list` | Delete the customer-list audience |
| `POST` | `/api/v1/partner/audiences/customer-list/lookalike` | Add the 1 % lookalike |
| `POST` | `/api/v1/partner/declared-source` | Record a customer's "how did you find us?" answer from your WhatsApp conversation |
| `POST` | `/api/v1/partner/wa-joins` | Join an inbound WhatsApp message to the web session that produced it |
| `GET` | `/api/v1/partner/whatsapp/ads` | List the profile's click-to-WhatsApp ads |
| `GET` | `/api/v1/partner/whatsapp/ads/{ad_id}/welcome-message` | Read a click-to-WhatsApp ad's welcome message |
| `POST` | `/oauth/revoke` | RFC 7009 token revocation |
| `POST` | `/oauth/token` | OAuth 2.0 token endpoint |

Generated from `openapi.json`. The full request and response schema for every operation is in the [OpenAPI document](https://www.atribu.app/api/v1/openapi.json).

{/* END GENERATED ENDPOINT TABLE */}

## Next steps

<Cards>
  <Card title="Connections" href="/docs/api/connections">
    Pending connects and the account picker
  </Card>
  <Card title="Workspaces & profiles" href="/docs/api/workspaces">
    The objects a provisioning call creates
  </Card>
  <Card title="Webhooks" href="/docs/api/webhooks">
    Learning that a merchant finished a connect
  </Card>
</Cards>

---

# SDKs

Atribu ships two official SDKs. Both are open-source, MIT-licensed, and published to npm with full provenance attestations.

<Cards>
  <Card title="@atribu/node" href="/docs/sdks/node">
    Server-side Node.js SDK. Authorize users, send WhatsApp & Instagram
    messages, verify signed webhook deliveries, and run the consumer-side
    OAuth 2.0 flow.
  </Card>
  <Card title="@atribu/tracker" href="/docs/sdks/tracker">
    Browser tracking SDK. Page views, sessions, engagement, outbound links,
    file downloads, form submissions, booking detection — all auto-captured
    via one <code>init()</code> call.
  </Card>
</Cards>

## Which one do I need?

| You want to ... | Use |
|---|---|
| Send messages on behalf of users who connected their WhatsApp/Instagram | `@atribu/node` |
| Receive signed webhook events from Atribu (incoming messages, deliveries, comments) | `@atribu/node` |
| Build a consumer app that uses Atribu as an OAuth provider for Meta connections | `@atribu/node` |
| Track web visitors and attribute pageviews / conversions to ad campaigns | `@atribu/tracker` |
| Capture form submissions, file downloads, scroll depth — all auto, no instrumentation | `@atribu/tracker` |
| Run cross-domain attribution between marketing site + app | `@atribu/tracker` |

The two SDKs are independent — you can install either, both, or neither. Most teams will install `@atribu/tracker` on their marketing site and only adopt `@atribu/node` if they're building a consumer app that hooks into Atribu's OAuth provider surface.

## Quick install

```bash title="Server-side"
npm install @atribu/node
```

```bash title="Browser tracker"
npm install @atribu/tracker
```

## Source

Both packages are developed in the open at [github.com/AtribuCore](https://github.com/AtribuCore):

- [AtribuCore/atribu-node](https://github.com/AtribuCore/atribu-node) — Node.js SDK
- [AtribuCore/atribu-tracker](https://github.com/AtribuCore/atribu-tracker) — Browser tracker

Releases are published via GitHub Actions OIDC trusted publishing — every npm version comes with a Sigstore attestation linking back to the exact CI run that built it.

---

# @atribu/node

<Callout type="info">
**Published:** [`@atribu/node` on npm](https://www.npmjs.com/package/@atribu/node) · **Source:** [github.com/AtribuCore/atribu-node](https://github.com/AtribuCore/atribu-node)
</Callout>

`@atribu/node` is the official Node.js SDK for consumer apps that integrate with Atribu's public API. It wraps every OAuth-consumer endpoint (`/api/v1/messages`, `/api/v1/comments`, `/api/v1/webhooks/*`, the OAuth provider flow at `/oauth/*`) with typed inputs/responses, drop-in webhook verification, and a small typed error hierarchy.

## When to use it

If you're building an app that:

- Sends WhatsApp or Instagram messages on behalf of users you've authorized via Atribu's OAuth provider flow
- Receives signed webhook deliveries from Atribu (inbound messages, delivery receipts, comments)
- Replies to Instagram comments programmatically
- Manages your webhook subscriptions (create, rotate secrets, replay deliveries)

If you're just hitting the [public REST API](/docs/api/quickstart) with `fetch()` and want type safety without writing the types yourself.

## Install

```bash
npm install @atribu/node

# Optional peer deps:
npm install jose      # only if you use @atribu/node/oauth
npm install msw       # only if you use @atribu/node/test
```

<Callout type="info" title="Runtime support">
Node 18+, Bun, Deno (`npm:@atribu/node`), Vercel Edge, Cloudflare Workers. Uses Web Crypto throughout — no `node:crypto` imports.
</Callout>

## Quick start — send a WhatsApp message

```typescript
import { AtribuClient } from "@atribu/node";

const atribu = new AtribuClient({ apiKey: process.env.ATRIBU_API_KEY });

const result = await atribu.messages.send({
  connection_id: "11111111-1111-1111-1111-111111111111",
  channel: "whatsapp",
  to: "+15551234567",
  content: { type: "text", text: "Hello from @atribu/node!" },
});

console.log("Sent:", result.provider_message_id);
```

## Authentication

`AtribuClient` accepts your `atb_live_*` API key. Get one from **Settings → Developer** in the Atribu dashboard.

```typescript
new AtribuClient({
  apiKey: "atb_live_...",                  // required
  baseUrl: "https://www.atribu.app",       // default
  fetch: customFetch,                       // optional — bring your own (tracing, edge)
  timeoutMs: 30_000,                        // default 30s
  userAgent: "MyApp/1.0",                   // appended after the SDK User-Agent
});
```

`Idempotency-Key` headers are auto-sent on every mutating POST. The server's `X-Request-Id` is surfaced as `err.requestId` on errors for log correlation.

<Callout type="warn" title="To retry a SEND safely, pass your own key">
The auto-sent key is generated fresh **per attempt**, so it deduplicates
nothing — not even this SDK's own `RetryingHttpClient` replays. The send routes
honour a key you supply (`{ idempotencyKey }`), replaying the first response
for **24 hours** and refusing a retry that arrives while the first send is
still in flight. See
[Retrying a send safely](/docs/api/messaging#retrying-a-send-safely--idempotency-key).

```typescript
await atribu.messages.send(input, { idempotencyKey: message.id });
await atribu.conversations.sendMessage(id, { text }, { idempotencyKey: message.id });
```
</Callout>

## Server-side purchase tracking

The most reliable way to tie a sale to the ad that drove it: send the purchase from your **server of record** with the `anonymousId` you captured in the browser. It survives ad-blockers and ITP, the amount is authoritative, and it's idempotent on your `orderId`.

The flow is always the same — **capture the `anonymousId` client-side, send the purchase server-side:**

**1. Client — grab the attribution and hand it to your server.** The browser tracker's [`getAttribution()`](/docs/sdks/tracker#attribution-passthrough-checkout) returns the `anonymous_id` (and click-ids). Post it to your checkout/confirm endpoint (never expose your `atb_live_*` key to the browser).

```typescript
// browser, on/just before checkout
const { anonymous_id, session_id } = window.atribuTracker.getAttribution();
await fetch("/api/confirm-order", {
  method: "POST",
  body: JSON.stringify({ anonymousId: anonymous_id, sessionId: session_id, cart }),
});
```

**2a. Next.js — a route handler / server action does the charge, then records the purchase:**

```typescript
// app/api/confirm-order/route.ts   (server-only — the API key lives here)
import { AtribuClient } from "@atribu/node";

const atribu = new AtribuClient({ apiKey: process.env.ATRIBU_API_KEY! });

export async function POST(req: Request) {
  const { anonymousId, sessionId, cart } = await req.json();
  const order = await chargeAndCreateOrder(cart); // your payment of record

  await atribu.events.purchase({
    anonymousId,               // ← the deterministic ad link
    sessionId,
    value: order.total,
    currency: order.currency,  // e.g. "USD", "CLP"
    orderId: order.id,         // ← idempotency key: retries never double-count
    userTraits: { email: order.email },
  });

  return Response.json({ ok: true });
}
```

**2b. Node (Express / Fastify / any webhook handler)** — identical, framework-agnostic:

```typescript
import { AtribuClient } from "@atribu/node";
const atribu = new AtribuClient({ apiKey: process.env.ATRIBU_API_KEY! });

app.post("/confirm-order", async (req, res) => {
  const order = await chargeAndCreateOrder(req.body.cart);
  await atribu.events.purchase({
    anonymousId: req.body.anonymousId,
    value: order.total,
    currency: order.currency,
    orderId: order.id,
    userTraits: { email: order.email },
  });
  res.json({ ok: true });
});
```

`events.purchase` uses your `orderId` as the idempotency key, so a retried request (or an at-least-once webhook) collapses to a single event — no double-count. For any other event, use `atribu.events.track({ event_name, anonymous_id, properties, idempotency_key })`.

`subjectRef` (optional) is for one customer paying for several people — a parent paying for each child's treatment. Pass your own opaque id for who the purchase is for — a canonical UUID or a short token `[A-Za-z0-9_-]{1,32}` — and each distinct value gets its own first payment (if the customer's earlier payments carry a different one). Never personal data: an email, a RUT, a phone, a date, a name or a hex digest is rejected with `400`. See [one customer paying for several people](/docs/api/tracking).

<Callout type="info" title="Cash vs. gross">
By default `purchase` is recorded as a **gross** order. If this server-side purchase is your authoritative cash record (you don't also run a Stripe/MercadoPago webhook for the same sale), create a conversion goal that maps `purchase` → **cash** in **Settings → Goals** (or `atribu` goals API). Don't map it to cash if a payment-provider webhook already reports the same sale — that would count the cash twice.
</Callout>

## Send messages

<Tabs items={["WhatsApp text", "WhatsApp template", "WhatsApp image", "Instagram text", "Instagram image", "Email"]}>
<Tab value="WhatsApp text">
```typescript
await atribu.messages.send({
  connection_id: connectionId,
  channel: "whatsapp",
  to: "+15551234567",
  content: { type: "text", text: "Hello!" },
});
```
</Tab>
<Tab value="WhatsApp template">
```typescript
await atribu.messages.send({
  connection_id: connectionId,
  channel: "whatsapp",
  to: "+15551234567",
  content: {
    type: "template",
    template_name: "appointment_reminder",
    language_code: "en_US",
    components: [
      { type: "body", parameters: [{ type: "text", text: "Tuesday at 3pm" }] },
    ],
  },
});
```
</Tab>
<Tab value="WhatsApp image">
```typescript
// Pre-uploaded media (recommended for high fanout — Meta caches it 30 days):
await atribu.messages.send({
  connection_id: connectionId,
  channel: "whatsapp",
  to: "+15551234567",
  content: {
    type: "image",
    media: { media_id: "1234567890" },
    caption: "Your invoice",
  },
});

// Or by public HTTPS link (Meta fetches once per send):
await atribu.messages.send({
  connection_id: connectionId,
  channel: "whatsapp",
  to: "+15551234567",
  content: {
    type: "image",
    media: { link: "https://cdn.example.com/invoice.png" },
  },
});
```
</Tab>
<Tab value="Instagram text">
```typescript
await atribu.messages.send({
  connection_id: connectionId,
  channel: "instagram",
  to: "17841400000000001",  // IGSID
  content: { type: "text", text: "Hi from Instagram DM!" },
});
```
</Tab>
<Tab value="Instagram image">
```typescript
await atribu.messages.send({
  connection_id: connectionId,
  channel: "instagram",
  to: "17841400000000001",
  content: {
    type: "image",
    image_url: "https://cdn.example.com/image.png",
  },
});
```
</Tab>
<Tab value="Email">
```typescript
// START a thread: `to` + a subject, and no thread anchors at all.
const sent = await atribu.messages.send({
  connection_id: connectionId,
  channel: "email",
  to: "alice@example.com",
  content: {
    type: "email",
    subject: "Your quote",
    text: "Hi Alice — the quote is attached.",
    // Bulk mail only. One-click needs an https:// url (RFC 8058).
    list_unsubscribe: { url: "https://example.com/unsubscribe?t=abc", one_click: true },
  },
});

// Keep these two. `thread_id` is the same identifier the inbound
// `message.received` event carries, so it is the thread the reply arrives on;
// `rfc822_message_id` is what a bounce names in
// `delivery_failure.original_rfc822_message_id`.
sent.thread_id;          // "18f2a1c9d4e5b6a7" — the thread just created
sent.rfc822_message_id;  // "<CAF=...@mail.gmail.com>"

// REPLY in-thread: round-trip the anchors from the inbound event. The response
// then names the thread it replied into, not a new one.
await atribu.messages.send({
  connection_id: connectionId,
  channel: "email",
  to: "alice@example.com",
  content: {
    type: "email",
    text: "Tomorrow at 10 works.",
    thread_id: event.data.thread_id,
    in_reply_to: event.data.rfc822_message_id,
    references: event.data.references,
    // Outlook only — Graph then owns the threading against the original.
    reply_to_message_id: event.data.message_id,
  },
});
```
</Tab>
</Tabs>

<Callout type="info" title="Not every inbound email is a person writing back">
`message.received` for `provider: "email"` carries three fields that say what
kind of message it is, and a follow-up sequence should read all three before
treating one as an answerable reply: **`auto_submitted`** (the header value,
lowercased — a vacation responder), **`precedence`** (`"bulk"`, `"list"`, …) and
**`delivery_failure`**, which is `null` for an ordinary message and otherwise
`{ failed_recipient, status, action, diagnostic_code, original_rfc822_message_id }`
parsed out of the delivery-status report the bounce itself carries. `status` is
the enhanced status code verbatim — `5.x.x` is a hard bounce (stop sending to
that address), `4.x.x` is transient — and `action` says what the reporting
server did (`"failed"`, or `"delayed"` while it is still retrying).
`diagnostic_code` is the remote server's own text, for display. `original_rfc822_message_id` equals the
`rfc822_message_id` your send returned, which is how a bounce attaches to the
exact message that failed instead of being guessed at from the postmaster's
address.
</Callout>

`atribu.messages.send` answers `thread_id` and `rfc822_message_id` on
`channel: "email"` only — neither concept exists on WhatsApp or Instagram, so
they are absent from those responses rather than present and null. On Outlook
`provider_message_id` is the Microsoft Graph immutable id of the sent message
(its copy in Sent Items). Inbound Outlook events carry Graph's default-format
id, so recognise your own message coming back by `rfc822_message_id`, which is
the same on both providers, never by comparing ids.

## Reply to Instagram comments

```typescript
// Public reply on the comment thread:
await atribu.comments.reply({
  comment_id: "ig_comment_id",
  connection_id: connectionId,
  text: "Thanks! DMing you now.",
});

// Private DM to the commenter (comment-to-DM flow):
await atribu.comments.privateReply({
  comment_id: "ig_comment_id",
  connection_id: connectionId,
  text: "Here are the details ...",
});
```

## List authorized connections

Every method that takes a `connection_id` only succeeds against connections the calling key is authorized for. To enumerate them up front:

```typescript
const connections = await atribu.connections.list();
for (const conn of connections) {
  console.log(`${conn.channel} — ${conn.display_name} (${conn.id})`);
}

// Filter by channel:
const igOnly = await atribu.connections.list({ channel: "instagram" });

// Revoke this OAuth app's authorization for a connection.
// Other consumers + the Atribu app UI keep using it; only this app loses access.
await atribu.connections.revoke(connectionId);
```

Direct admin keys (issued from Settings → Developer) see every connected connection on the profile and **cannot self-revoke** — `connections.revoke()` returns `400 invalid_request` for them.

## WhatsApp templates

`messages.send({ content: { type: "template", ... } })` only works against templates that have been Meta-approved. Create them first, then poll until `status === "APPROVED"`:

```typescript
// 1. List existing templates (all statuses).
const templates = await atribu.whatsapp.templates.list({ connectionId });

// 2. Create a new one. Body text supports `{{param_name}}` placeholders;
// the named-params example block is auto-generated.
const { id, status } = await atribu.whatsapp.templates.create({
  connection_id: connectionId,
  name: "appointment_reminder",
  category: "UTILITY", // or "AUTHENTICATION" | "MARKETING"
  language: "en_US",
  body_text: "Hi {{customer_name}}, your appointment is at {{appointment_time}}.",
  header_text: "Atribu Health",        // optional
  footer_text: "Reply STOP to opt out", // optional
});

// 3. Delete by name once obsolete.
await atribu.whatsapp.templates.delete("appointment_reminder", { connectionId });
```

<Callout type="info">
Template name must be lowercase letters, digits and underscores only (`^[a-z0-9_]+$`). Meta enforces the constraint server-side; the SDK + API validate before submission to fail fast.
</Callout>

## WhatsApp broadcasts

Two-step flow — create the draft + recipient list, then call `send` to dispatch:

```typescript
// 1. Create a draft. Max 1,000 recipients per broadcast.
const broadcast = await atribu.whatsapp.broadcasts.create({
  connection_id: connectionId,
  template_name: "appointment_reminder",
  template_language: "en_US",
  recipients: customers.map((c) => ({
    phone_number: c.phone,
    template_params: { customer_name: c.name, appointment_time: c.timeIso },
  })),
  name: "Q2 appointment reminders",
});

// 2. Dispatch. Long-running — paces 100ms between recipient sends and
// returns when every recipient has been attempted. The server caps the
// route at 5 minutes; for larger sends extend `timeoutMs` on AtribuClient.
const completed = await atribu.whatsapp.broadcasts.send(broadcast.id);
console.log(`sent: ${completed.sent_count}, failed: ${completed.failed_count}`);
```

To inspect or cancel:

```typescript
// Get broadcast + first 200 recipient rows with delivery state:
const detail = await atribu.whatsapp.broadcasts.get(broadcast.id);
for (const r of detail.recipients) {
  console.log(`${r.phone_number} → ${r.status}`);
}

// Cancel an in-flight broadcast. Recipients not yet sent stay `pending`
// permanently; already-sent messages are NOT recalled.
await atribu.whatsapp.broadcasts.cancel(broadcast.id);
```

## WhatsApp interactive buttons

`messages.send` supports the `interactive_buttons` content type for WhatsApp — up to 3 reply buttons rendered below a body string. Taps return as `messaging_postbacks` webhook events with the button `id`.

```typescript
await atribu.messages.send({
  connection_id: connectionId,
  channel: "whatsapp",
  to: "+15551234567",
  content: {
    type: "interactive_buttons",
    body: "Pick a plan:",
    header: "Pricing",               // optional, 60 char max
    buttons: [
      { id: "plan_basic", title: "Basic" },
      { id: "plan_pro", title: "Pro" },
      { id: "plan_enterprise", title: "Enterprise" },
    ],
  },
});
```

## WhatsApp interactive lists

`messages.send` also supports `interactive_list` for WhatsApp — a body, a button that opens a picker, and up to 10 rows in sections. A selected row returns its `id` as an inbound `interactive.list_reply`. Every section needs a `title` when there is more than one. Free-form, so only inside the 24h customer-service window.

```typescript
await atribu.messages.send({
  connection_id: connectionId,
  channel: "whatsapp",
  to: "+15551234567",
  content: {
    type: "interactive_list",
    body: "Which unit type?",
    button_text: "See options",      // 20 char max
    footer: "Pick one",              // optional, 60 char max
    sections: [
      { title: "Project A", rows: [{ id: "a-1d", title: "1D1B", description: "32 m²" }] }, // title 24, description 72
      { title: "Project B", rows: [{ id: "b-2d", title: "2D1B" }] },
    ],
  },
});
```

## Click-to-WhatsApp ads and their greeting

A click-to-WhatsApp ad shows the customer the business's own greeting — the
"Saludo automático" — before they type anything, plus either a pre-filled first
message or ice-breaker buttons. Meta never delivers any of it over the webhook:
the `referral` object on the customer's first inbound message carries the ad's
headline, body and thumbnail and stops there. So 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.

`ads.list()` answers about the whole account, with no conversation required.

```typescript
let after: string | undefined;
do {
  const page = await atribu.whatsapp.ads.list({ after });
  for (const ad of page.ads) {
    // `greeting_status`, never `greeting === null`: `none` is a normal ad whose
    // advertiser configured no greeting, `unreadable` is a creative Meta would
    // not show us — worth asking again once the Meta connection is fixed.
    if (ad.greeting_status === "none") {
      console.log(`${ad.ad_name} opens ${ad.destination.whatsapp_phone_number} with no greeting`);
    }
  }
  after = page.nextCursor ?? undefined;
} while (after);
```

The list is served from Atribu's nightly Meta sync, never a live Graph call, so
polling it costs the advertiser's `ads_management` quota nothing. Each row
carries `last_synced_at`; an ad launched since the last sync is not listed yet.

`ads.welcomeMessage(adId)` answers about one ad — and that ad id is
`referral.source_id` on the customer's first inbound message, which is how you
learn it in the first place.

```typescript
const { welcome, cached, fetched_at } = await atribu.whatsapp.ads.welcomeMessage(
  referral.source_id,
);

// `welcome: null` means the ad has no greeting configured — a complete answer,
// not a failure. Check the field; do not rely on a throw.
if (welcome?.autofill_message && firstMessage === welcome.autofill_message) {
  // They tapped. They did not type this.
}
```

It answers a fresh synced row without touching Meta and falls back to a live
read for an ad the sync has not seen. `AtribuApiError` with status `404` means
Meta does not show Atribu the ad (deleted, or invisible to this workspace's
token — Graph does not distinguish the two), `409` that the profile has no
usable Meta Ads connection, `502` a transient Meta failure worth retrying.

## Instagram comment-to-DM triggers

When a user comments with a configured keyword, Atribu DMs them automatically and (optionally) leaves a public reply on the comment.

```typescript
// Create a trigger.
const trigger = await atribu.instagram.triggers.create({
  connection_id: connectionId,
  keyword: "PRICE",
  keyword_match_mode: "contains",          // "contains" | "exact" | "regex"
  case_sensitive: false,
  opening_message: "Here's our pricing — happy to chat!",
  public_comment_reply: "Sent you a DM!",  // optional
  agent_context_hint: null,                // optional — gets passed to the AI agent
  enabled: true,
});

// Test the opening_message against a real IGSID (must have DMed your IG account
// in the past 7 days for Meta to accept the HUMAN_AGENT send).
await atribu.instagram.triggers.testDm(trigger.id, {
  recipient_igsid: "1234567890",
});

// Pause / update / delete:
await atribu.instagram.triggers.update(trigger.id, { enabled: false });
await atribu.instagram.triggers.delete(trigger.id);
```

If the comment-to-DM circuit trips after a spam wave, you can clear it from the SDK:

```typescript
await atribu.instagram.triggers.resumeCircuit({ connectionId });
```

## Commerce (Shopify)

A connected Shopify store gives you two things: a **catalogue you pull** and
**transitions Atribu pushes**. The split is deliberate — an event tells you
something changed, the API tells you what it now is. No commerce event ever
carries the catalogue.

### Pull: products and orders

```typescript
// The catalogue, as a CHANGE FEED. Keep the newest `updated_at` you have seen
// and pass it back — the page order is ascending, so a cursor stays valid no
// matter how many products change while you walk.
let updatedSince = lastSeen;            // from your own store
let cursor: string | undefined;
do {
  const page = await atribu.commerce.products.list({
    updated_since: updatedSince,
    limit: "100",
    ...(cursor ? { cursor } : {}),
  });
  for (const product of page.data) {
    // `product.variants[]` carries sku, price, currency, inventory_quantity
    // and `inventory_state` (in_stock | low_stock | out_of_stock | discontinued).
    // `active: false` means the store deactivated or DELETED it — mirror that.
    upsertLocally(product);
  }
  cursor = page.pagination.cursor;
} while (cursor);

// "Where is my order" — by number, email or phone. At LEAST ONE is required.
const orders = await atribu.commerce.orders.list({ phone: senderPhone });
const latest = orders.data[0];          // newest first
// latest.status: placed | paid | fulfilled | delivered | cancelled
```

`commerce.orders` returns **no customer identity** — no name, no email, no
phone. You already hold the contact you searched with, and the same row is
reachable by order number, so returning one would turn a guessable reference
into a lookup of the person behind it.

Prices are **exact decimal strings**, never JSON numbers. Parse before doing
arithmetic, and keep sorting server-side: `"9.00" > "10.00"` lexically, and that
failure is silent.

### Push: the five `shopify` event kinds

| Event | Fires when | `data` carries | What it is for |
| --- | --- | --- | --- |
| `catalog.updated` | a synced page of products changed | `updated_since`, `product_count` | A **tickle**. Pull with `commerce.products.list({ updated_since })`. |
| `product.stock.changed` | a variant CROSSES a stock threshold | `variant_id`, `product_id`, `previous_state`, `state`, `quantity` | Back-in-stock and low-stock follow-ups. |
| `product.price.changed` | a variant's price DROPS | `variant_id`, `product_id`, `previous_price`, `price` | Price-drop follow-ups. |
| `checkout.abandoned` | a checkout is abandoned | `checkout_id`, `email`, `phone`, `abandoned_checkout_url` | Cart-recovery outreach. |
| `order.status.changed` | an order is placed, paid, fulfilled, delivered, refunded or cancelled | `order_id`, `status`, `name`, `financial_status`, `fulfillment_status`, `email`, `phone`, `attributes`, `lines`, `total_price`, `currency` | Matching a store order to your own cart or contact, and shipping notifications. |
| `customer.redacted` | Shopify sends `customers/redact` for a buyer of the store | `shop`, `customer_id`, `email`, `phone`, `orders_to_redact` | Erasing that buyer's personal data from the orders you hold. |
| `shop.redacted` | Shopify sends `shop/redact`, 48 h after the store uninstalled the app | `shop` | Erasing the buyer data of every order you hold from that store. |

Subscribe to them like any other event:

```typescript
await atribu.webhooks.subscriptions.create({
  url: "https://your.app/api/atribu-webhook",
  events: ["product.stock.changed", "order.status.changed"],
  providers: ["shopify"],
});
```

Four properties worth relying on:

- **Transitions only.** `product.stock.changed` fires when the band changes, not
  on every inventory write, and `previous_state` and `state` always differ.
  `product.price.changed` is drop-only: `price < previous_price` always holds.
- **One fact, one event id.** A checkout gets ONE `checkout.abandoned` however
  many times the store updates it, and an order gets one
  `order.status.changed` per `(order, status)` — Shopify fires `orders/updated`
  for a tag or a note, and those must not re-notify the customer. Deduplicate on
  `event.id` anyway: delivery is at-least-once.
- **`status` is the order's current milestone**: `cancelled` > `refunded` >
  `delivered` > `fulfilled` > `paid` > `created`. An order fires when it is placed
  (`created`, or `paid` when it was paid at checkout — `orders/create` and
  `orders/paid` collapse into one event) and again at each later milestone.
  `fulfilled` and `delivered` are two events, because they are two things a
  customer wants to hear about.
- **Matching an order.** Cart attributes arrive in `data.attributes`: an order
  placed from `/cart/<variant>:<qty>?attributes[my_ref]=X` carries
  `attributes.my_ref === "X"`. `data.lines[]` holds `variant_id` and `quantity`
  per line; `financial_status` and `fulfillment_status` are Shopify's own
  (lowercase). `email` / `phone` are the buyer's, null when Shopify withholds
  them (buyer contact needs the app's Protected Customer Data approval).
- **Contacts are frequently null.** `checkout.abandoned` carries whatever the
  shopper had typed when they left. A recovery flow has to tolerate having
  neither an email nor a phone rather than assuming one.
- **Redactions are forwarded, not optional.** Shopify sends its GDPR redaction
  webhooks only to Atribu. Atribu erases its own copy and then forwards them as
  `customer.redacted` / `shop.redacted`, so an app that stored buyer data from
  these events can erase it too. The forward is retried until it is enqueued;
  its id repeats for a redelivery of the same request, so de-dupe on `id`.

## Platform lifecycle: the nine `atribu` event kinds

Everything above is something that happened at a **channel** — a message, a
comment, a product. These nine are something that happened to **your integration
with Atribu**: a connector went live or died, a recompute finished, a goal
produced its first attributed conversion.

They exist so you never have to poll. Before them, `GET /api/v1/profile/freshness`
was documented as the one route a consumer was expected to poll, and connect and
export completion had no signal at all.

**They are opt-in, and the opt-in is the `providers` array, not `events`.**
Delivery matches on providers **and** events, so a subscription whose
`providers` is `["whatsapp"]` receives none of these however you set `events`:

```typescript
await atribu.webhooks.subscriptions.create({
  url: "https://your.app/api/atribu-webhook",
  events: [
    "connection.connected",
    "connection.reconnect_required",
    "recompute.completed",
    "conversion.attributed",
  ],
  providers: ["atribu"],
});
```

| Event | Fires when | `data` carries |
| --- | --- | --- |
| `connection.connected` | an OAuth connect (or re-connect) finishes and Atribu can read the connector | `provider`, `external_account_id`, `external_account_name`, `reconnected` |
| `connection.reconnect_required` | a connector's grant died and only a human re-consent fixes it | `provider`, `status`, `reason`, `reconnect: { method, path }` |
| `connection.revoked` | a connector was removed — nothing to repair | `provider`, `external_account_id`, `reason` |
| `handoff.completed` | a pending hand-off (`connect`, `pick`, `sign_dpa`, `checkout`, `approve`) was completed by a human | `handoff_id`, `kind`, `result` |
| `recompute.completed` | an attribution recompute finished for the profile | `profile_id`, `skipped_early_exit`, `conversion_id`, `attribution_model` |
| `conversion.attributed` | a conversion definition produces its **first** attributed conversion | `conversion_definition_id`, `conversion_key`, `display_name`, `revenue_type`, `conversion_id`, `conversion_time` |
| `export.completed` | a conversion-export run finished with nothing failed | `profile_id`, `meta`, `google`, `duration_ms`, `replay` |
| `export.failed` | a conversion-export run finished with at least one failed candidate | same shape as `export.completed` |
| `profile.freshness.changed` | the profile's attribution freshness advanced | `profile_id`, `recomputed_at`, `status`, `previous_recomputed_at` |

Six properties worth relying on:

- **`connection_id` is `null` on the ones with no connector behind them** —
  `recompute.completed`, `conversion.attributed`, `profile.freshness.changed`,
  and `export.*` (an export run spans every configured destination, each with
  its own connection, so there is no single one to name). Every `connection.*`
  event always carries it. The SDK types `connection_id` as `string | null`
  for exactly this reason — narrow before you use it.
- **`data.provider` carries the connector's real provider** on `connection.*` —
  `meta_ads`, `google_ads`, `google_search_console`, `gohighlevel`,
  `whatsapp`, … The top-level `provider` is always `"atribu"`, and is what the
  subscription matched on.
- **`conversion.attributed` fires exactly once per definition, ever.** The
  "first" is recorded durably on the definition row, so a restart, a replay or a
  redelivery cannot re-announce a milestone that already passed. This is the
  onboarding signal: it is the moment a goal stops being configuration and
  starts being data.
- **`connection.reconnect_required` gives you a route, not a link.** `data.reconnect`
  is `{ method: "POST", path: "/api/v1/connections/{provider}/handoff" }` —
  substitute `data.provider` and call it with your own credential. A signed
  reconnect URL is a capability, and Atribu will not mint one on a failure path.
  The same object appears on the error envelope of a 401 with
  `reconnect_required: true`.
- **`export.failed` is about `failed`, never `skipped`.** A run that correctly
  suppressed every candidate (consent withheld, a late sibling already sent, a
  legal gate) is `export.completed` with `sent: 0`. Both events carry the whole
  run's counts, so a partial run tells you it sent 400 and failed 2.
- **A run with nothing to do emits nothing.** The hourly export sweep finding no
  candidates, or a recompute that never started, are silent — the events mark
  work that happened, not clocks that ticked.
- **`handoff.completed` fires exactly once, and only on completion.** The
  transition is guarded on `status = 'pending'`, so a human double-tapping the
  button, a redelivered callback and a lost race all settle without
  re-announcing. A hand-off that `failed` or was `cancelled` emits nothing —
  an agent polling it reads those off the object it already holds. A
  WORKSPACE-level hand-off (`checkout`, minted without a profile) emits nothing
  either: subscriptions are per profile, and there is no honest profile to
  attribute it to.

Deduplicate on `event.id` as always: delivery is at-least-once. The ids are
keyed on the FACT, so a redelivery of the same connect, the same first
attribution or the same export run collapses.

## Webhook subscriptions

```typescript
// Create — secret is shown ONCE in the response
const sub = await atribu.webhooks.subscriptions.create({
  url: "https://your.app/api/atribu-webhook",
  events: ["message.received", "message.delivery"],
  providers: ["whatsapp", "instagram"],
});
console.log("Save this secret somewhere safe:", sub.secret);

// Rotate the HMAC secret — deploy dual-verify on your side BEFORE calling
const rotated = await atribu.webhooks.subscriptions.rotateSecret(sub.id, {
  grace_days: 14,
});

// Fire a synthetic event to test your handler end-to-end
await atribu.webhooks.subscriptions.test(sub.id);

// Re-deliver a webhook that failed
await atribu.webhooks.deliveries.replay(deadDeliveryId);
```

**Your endpoint must be a public host.** The URL has to be `https://` on a
resolvable public DNS name — no literal IPs, no `.internal` / `.local` /
tailnet names, no single labels, no embedded credentials, and every A/AAAA
record must be publicly routable (loopback, RFC 1918, `169.254.169.254`,
CGNAT `100.64/10`, unique-local and their IPv6-mapped forms are all refused).
A URL that fails answers `422` naming the address and the range. The same check
runs again immediately before each delivery, so a host that stops being public
later stops receiving events — and **redirects are not followed**, so register
the final URL rather than one that `3xx`s to it.

**Any `atb_live_` key can manage subscriptions for its own profile.** These
routes used to answer `403 forbidden` — *"Subscription management is only
available to keys issued via the OAuth flow"* — to a key a workspace minted for
itself, so the persona most likely to want push instead of polling was the one
persona that could not have it. That gate is gone: a self-serve key creates,
lists, patches, rotates, tests and deletes subscriptions on its own profile,
through the same validation and the same profile scoping an OAuth-app key gets.
Nothing changes for OAuth-app keys, and a self-serve key still cannot see or
touch an OAuth app's subscriptions (or vice versa).

**A `client_credentials`-delegated key is an OAuth-app key, not a third
kind.** An app-provisioned profile (no person ever signs into Atribu for it)
has nothing to mint a self-serve key with, so it manages its own subscriptions
through a key minted by `POST /oauth/token` with
`grant_type=client_credentials` — the same `oauth_app_id`-bearing key shape an
authorization-code exchange mints, just issued server-to-server instead of
through a consent redirect. It creates, lists, patches, rotates, tests and
deletes subscriptions on its own profile through this identical path: same
validation, same profile scoping, same signature — proven end to end,
including the four platform lifecycle events below, in `src/app/api/v1/
webhooks/subscriptions/client-credentials.integration.test.ts`.

`webhooks.subscriptions.test(id)` fires a synthetic matching the subscription's
FIRST provider: `message.received` for whatsapp/instagram/email/shopify,
`calendar.event.changed` for google_calendar, and `recompute.completed` for
`atribu` — which has no `message.received`, so testing an `atribu`-only
subscription exercises the parser you will actually need.

## Verifying webhooks

Atribu signs every outbound delivery as `X-Atribu-Signature: t=<unix>,v1=<hex_hmac_sha256>` over `<t>.<rawBody>` (Stripe-style). The SDK verifier handles parsing, timestamp tolerance, constant-time HMAC compare, and rotation grace.

<Tabs items={["Next.js App Router", "Manual verification"]}>
<Tab value="Next.js App Router">
```typescript title="app/api/atribu-webhook/route.ts"
import { withAtribuWebhook } from "@atribu/node/next";

export const POST = withAtribuWebhook({
  secret: process.env.ATRIBU_WEBHOOK_SECRET!,
  previousSecret: process.env.ATRIBU_WEBHOOK_PREVIOUS_SECRET,
  onEvent: async (event) => {
    if (event.type === "message.received" && event.provider === "whatsapp") {
      // event.data.wa_message_id, event.data.from, event.data.text — all typed
      console.log(`WA from ${event.data.from}: ${event.data.text}`);
      // Media messages include a hosted, browser-fetchable URL (signed, ~7-day
      // TTL) — same `attachments` shape as Instagram, so no extra call needed:
      // event.data.attachments = [{ type: "image", payload: { url } }]
    }
  },
});
```

For media messages, Atribu resolves Meta's opaque `media_id` for you and
includes an `attachments:[{ type, payload: { url } }]` array in the delivery
(`type` ∈ `image`/`video`/`audio`/`file`). If you only kept the `media_id` and
want to resolve it later, call `atribu.whatsapp.media.get(mediaId, { connectionId })`
→ `{ url, mime_type, expires_at }` (requires the `whatsapp` scope; media IDs
expire 7 days after the webhook).
</Tab>
<Tab value="Manual verification">
```typescript
import { verifyWebhook } from "@atribu/node/webhooks";

export async function POST(req: Request) {
  try {
    const event = await verifyWebhook({
      rawBody: await req.text(),
      signature: req.headers.get("x-atribu-signature"),
      secret: process.env.ATRIBU_WEBHOOK_SECRET!,
      previousSecret: process.env.ATRIBU_WEBHOOK_PREVIOUS_SECRET,
      tolerance: 300,
    });
    // ... handle typed event
    return new Response(null, { status: 200 });
  } catch {
    return new Response("invalid signature", { status: 401 });
  }
}
```
</Tab>
</Tabs>

The unique `event.id` and the `X-Atribu-Delivery-Id` header give you idempotency keys for safe redelivery.

## OAuth flow (consumer side)

If you're building an app that connects your end-users' WhatsApp/Instagram accounts through Atribu's OAuth provider, the `@atribu/node/oauth` subpath has every helper.

<Steps>
<Step>

### Mint an `id_token_hint` and redirect to consent

```typescript
import {
  buildAuthorizeUrl,
  signIdTokenHint,
  generateCodeVerifier,
  computeCodeChallenge,
} from "@atribu/node/oauth";

const codeVerifier = generateCodeVerifier();
const idTokenHint = await signIdTokenHint({
  jwtSigningSecret: process.env.ATRIBU_APP_JWT_SECRET!,
  subject: user.id,
  email: user.email,
  expiresIn: "5m",
  // Your own id for the merchant this consent is for. Required once Atribu has
  // set your app to `profile_resolution: "external_ref"`; ignored before that,
  // so it is safe to send early. See below.
  externalRef: merchant.id,
});

const url = buildAuthorizeUrl({
  clientId: "your-app-id",
  redirectUri: "https://your.app/integrations/atribu/callback",
  provider: "whatsapp",
  scope: "whatsapp",
  state: csrfToken,
  idTokenHint,
  codeChallenge: await computeCodeChallenge(codeVerifier),
  codeChallengeMethod: "S256",
});

// Redirect the user to `url`
```

<Callout type="info" title="`externalRef` — naming the profile yourself">
Without it, Atribu picks the profile from the hint's `email`, which keys a
merchant on a person: one operator connecting two merchants lands both on one
profile. `externalRef` emits an `external_ref` claim so consent resolves
`(client_id, external_ref)` against the profile you already provisioned under
that same ref — and skips every email-keyed branch.

Once your app is on `external_ref` mode the claim is mandatory. Omit it and the
callback receives `error=invalid_request` with `error_description=external_ref_required`;
name a ref you never provisioned and it is `unknown_external_ref`, at which
point you provision the merchant and retry. Use the option, not an
`extraClaims` entry: the claim name is Atribu's contract, and a typo there
signs a token that verifies and then fails at consent.
</Callout>

</Step>
<Step>

### Exchange the code for an access token

```typescript
import { exchangeCode } from "@atribu/node/oauth";

const { accessToken, connectionId, scope, profileId } = await exchangeCode({
  clientId: "your-app-id",
  clientSecret: process.env.ATRIBU_APP_CLIENT_SECRET!,
  code: callbackQuery.code,
  redirectUri: "https://your.app/integrations/atribu/callback",
  codeVerifier,
});

// `accessToken` IS the Atribu API key — store it server-side, never expose to the browser.
```

</Step>
<Step>

### Revoke when the user disconnects

```typescript
import { revokeToken } from "@atribu/node/oauth";

await revokeToken({
  clientId: "your-app-id",
  clientSecret: process.env.ATRIBU_APP_CLIENT_SECRET!,
  token: accessToken,
});
```

</Step>
</Steps>

## Error handling

```typescript
import { AtribuApiError } from "@atribu/node";

try {
  await atribu.messages.send({ /* ... */ });
} catch (err) {
  if (err instanceof AtribuApiError) {
    switch (err.retry.action) {
      case "retry":           return queue.retry(job, { delay: 5_000 });
      case "retry_after":     return queue.retry(job, { delay: err.retry.retryAfterMs });
      case "refresh_token":   return refreshOAuthAndRetry();
      case "fix_and_retry":   logger.error("bad payload", { requestId: err.requestId }); break;
      case "do_not_retry":    logger.error("permanent failure", { requestId: err.requestId }); break;
    }
  }
}
```

The SDK throws five typed error classes:

| Error | When |
|---|---|
| `AtribuApiError` | `/api/v1/*` returned non-2xx — has `code`, `status`, `requestId`, `retry`, `responseBody` |
| `AtribuOauthError` | RFC 6749/7009 error from `/oauth/*` |
| `AtribuWebhookError` | Signature verification failed |
| `AtribuTransportError` | Network glitch / timeout / abort |
| `AtribuConfigError` | Bad client configuration |

## Opt-in retries

The SDK doesn't retry automatically — hiding retries amplifies load on a failing server. Opt in per-client:

```typescript
const atribu = new AtribuClient({ apiKey: "..." }).withRetry({
  maxAttempts: 3,             // initial + 2 retries
  backoff: "exponential",     // or "fixed" or "none"
  baseDelayMs: 500,
  maxDelayMs: 30_000,
  jitter: 0.3,
});
```

| Condition | Behavior |
|---|---|
| 5xx, 408, network glitch | Exponential / fixed backoff with jitter |
| 429 / 503 with `Retry-After` | Honored exactly, no jitter |
| 401 (`refresh_token`) | Not retried — refresh credentials, don't retry |
| 422 (`fix_and_retry`) | Not retried — your input is bad |
| 403 (`do_not_retry`) | Not retried — permission denied |

## Conversion Sync

Rules, outcomes and wiring for the Meta destination.

```typescript
// Outcomes by Source with volume, CRM stages by pipeline, and the proposed Meta event per item.
const catalog = await atribu.conversionSync.outcomeCatalog();

// Up to 50 Sync Rules in one call; one result per row (`created` or `rejected` with its reason).
await atribu.exports.createRules({ rules });

// Campaigns -> ad sets with Meta's objective x event verdict per rule (replaces candidateAdSets()).
const wiring = await atribu.conversionSync.campaignWiring({ destination_id });

// Per rule: did Meta accept a real event in the last 7 days?
await atribu.conversionSync.verify();

// Connect ad sets to a rule — from any Source dataset, onto a Clean Dataset too. Recreates run as ONE background operation.
const { data } = await atribu.conversionSync.wireAdSets({ rule_id, destination_id, ad_set_ids });
if (data.operation) await atribu.conversionSync.operation(data.operation.operation_id); // per-ad-set progress; .operations() lists, .retryOperationItem(id, itemId) retries
```

Re-wiring the ad sets of a rule whose Meta event changed: `rewirePreview()`, `rewire()`, `pendingRewires()`.

**Breaking in 4.0.0:** `platformSafeRepointPreview()`, `applyPlatformSafeRepoint()` and `startPlatformSafeGoLive()` are removed, with the `platform-safe/repoint` and `platform-safe/go-live` endpoints behind them (#2208). Move ad sets onto a Clean Dataset with `campaignWiring()` + `wireAdSets()`.

**Breaking in 4.0.0 (#2207):** every Recreate asks what happens to the original ad set. `wireAdSets()` takes `pause_original: { [adSetId]: boolean }` and `rewire()` takes `ad_sets[].pause_original`: `true` pauses the original once its copy is live, `false` leaves it running. It is required, with no default, for every ad set that will be recreated (missing → `validation_error`, 400, naming the ad sets). `duplicate_swap_mode` is removed.

**Breaking in 3.0.0:** `exports.test(input)` now requires `test_event_code`. Without one the API answers `validation_error` (422) before Meta is called, because a send with no code landed on the live dataset as a real conversion. Copy the code from Events Manager > Test Events.

## Testing your integration

```typescript
import { setupServer } from "msw/node";
import { atribuMockHandlers, eventFixtures } from "@atribu/node/test";

const server = setupServer(...atribuMockHandlers({
  // Every endpoint has a realistic default; override only what you care about.
  messages: {
    send: { status: 422, body: { error: { code: "validation_error", message: "...", status: 422 } } },
  },
}));

// Drive your webhook handler tests with realistic event shapes:
const event = eventFixtures.whatsappMessageReceived({
  data: { text: "Custom test message" },
});
```

`msw@^2.0.0` is required as a peer dependency for this subpath.

## OpenTelemetry / Datadog APM / Sentry

Inject your own `fetch` to trace every SDK call — no SDK change needed:

```typescript
import { trace, context, propagation } from "@opentelemetry/api";
import { AtribuClient } from "@atribu/node";

const tracer = trace.getTracer("my-app");

const tracedFetch: typeof fetch = (input, init) =>
  tracer.startActiveSpan(`atribu.${(init?.method ?? "GET").toLowerCase()}`, async (span) => {
    const headers = new Headers(init?.headers);
    propagation.inject(context.active(), headers, { set: (h, k, v) => h.set(k, v) });
    try {
      const res = await fetch(input, { ...init, headers });
      span.setAttribute("http.status_code", res.status);
      const requestId = res.headers.get("x-request-id");
      if (requestId) span.setAttribute("atribu.request_id", requestId);
      return res;
    } finally { span.end(); }
  });

const atribu = new AtribuClient({ apiKey: "...", fetch: tracedFetch });
```

The SDK's `User-Agent` and Atribu's `X-Request-Id` give you log-grep correlation out of the box.

## Provenance + supply chain

Every published version of `@atribu/node` ships with a Sigstore attestation signed by GitHub Actions OIDC. The attestation links the npm tarball back to the exact CI workflow run that built it. You can verify it:

```bash
npm audit signatures
```

If you're auditing your supply chain, the `dist.attestations` field on each version on the npm registry contains the SLSA provenance URL.

---

# @atribu/tracker

<Callout type="info">
**Published:** [`@atribu/tracker` on npm](https://www.npmjs.com/package/@atribu/tracker) · **Source:** [github.com/AtribuCore/atribu-tracker](https://github.com/AtribuCore/atribu-tracker)
</Callout>

`@atribu/tracker` is a npm-installable alternative to the `<script src="atribu-tracker.js">` tag — same runtime, bundled inline, configured at `init()` time. Most teams using a modern frontend framework (React, Next.js, Vue, Svelte) will prefer this over the script tag.

## Install

```bash
npm install @atribu/tracker
```

## Quick start

```typescript
import { init } from "@atribu/tracker";

init({
  trackingKey: "trk_live_...", // from Tracking Settings → Ingest Keys
});
```

That's it. Page views, sessions, engagement, outbound links, file downloads, form submissions, and booking detection are all auto-captured.

## What's auto-captured

| Signal | Triggers |
|---|---|
| **Page views** | `init`, SPA navigation (`pushState`/`replaceState`/`popstate`/`hashchange`), bfcache restoration |
| **Sessions** | 30 min inactivity timeout (configurable 1–120 min) |
| **Engagement** | Scroll depth (px + %), time on page — sent on page exit |
| **Outbound links** | External link clicks via `sendBeacon` |
| **File downloads** | pdf · xlsx · docx · csv · zip · mp4 · and 30+ more |
| **Form submissions** | Standard `<form>` submits AND GHL-style `<div>` form fetches |
| **Booking detection** | Calendly · Cal.com · GoHighLevel · custom postMessage patterns |
| **Meta Pixel bridge** | Intercepts `fbq()` calls for server-side CAPI dedup |
| **Bot filtering** | Blocks bots; tags AI agents (ChatGPT, Claude, Perplexity, Gemini) |
| **Declarative events** | `data-atribu-event` HTML attributes — no-code event tracking |

## Configuration

```typescript
init({
  trackingKey: "trk_live_...",                  // required
  apiHost: "https://tracking.example.com",      // custom tracking domain (optional)
  trackingEndpoint: "...",                       // full override of collect URL (optional)
  interceptMetaFbq: true,                       // mirror Meta Pixel events (default true)
  metaBridgePageview: false,                    // mirror Meta PageView too (default false)
  customProperties: { tier: "pro" },            // static props on every event
  transformRequest: (event) => event,           // event middleware (return null to suppress)
  ignoredPages: ["/admin/*"],                   // URL patterns with `*` wildcards
  sessionTimeoutMinutes: 30,                    // 1–120 min
  sessionMode: "inactivity_only",               // or "inactivity_or_source_change"
});
```

## Framework setup

<Tabs items={["Next.js (App Router)", "React", "Vue 3", "Svelte", "Vanilla"]}>
<Tab value="Next.js (App Router)">
```tsx title="app/providers.tsx"
"use client";

import { useEffect } from "react";
import { init } from "@atribu/tracker";

export function AtribuProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    init({ trackingKey: "trk_live_..." });
  }, []);
  return <>{children}</>;
}
```

```tsx title="app/layout.tsx"
import { AtribuProvider } from "./providers";

export default function RootLayout({ children }) {
  return (
    <html><body><AtribuProvider>{children}</AtribuProvider></body></html>
  );
}
```
</Tab>
<Tab value="React">
```tsx
import { useEffect } from "react";
import { init } from "@atribu/tracker";

function App() {
  useEffect(() => {
    init({ trackingKey: "trk_live_..." });
  }, []);
  return <>{/* your app */}</>;
}
```
</Tab>
<Tab value="Vue 3">
```typescript
import { onMounted } from "vue";
import { init } from "@atribu/tracker";

onMounted(() => init({ trackingKey: "trk_live_..." }));
```
</Tab>
<Tab value="Svelte">
```typescript
import { onMount } from "svelte";
import { init } from "@atribu/tracker";

onMount(() => init({ trackingKey: "trk_live_..." }));
```
</Tab>
<Tab value="Vanilla">
```html
<script type="module">
  import { init, track } from "@atribu/tracker";

  init({ trackingKey: "trk_live_..." });

  document.querySelector("#signup-btn").addEventListener("click", () => {
    track("signup", { source: "landing" });
  });
</script>
```
</Tab>
</Tabs>

## Custom events

```typescript
import { track, trackRevenue } from "@atribu/tracker";

track("button_click", { label: "hero-cta" });

trackRevenue("purchase", 99.99, "USD", { plan: "enterprise" });
```

## User identification

Two complementary APIs:

```typescript
import { identify, setUserId } from "@atribu/tracker";

// PII-based — joins this visitor to the customer_profile via identity graph
identify({
  email: "user@example.com",
  firstName: "Jane",
  lastName: "Doe",
  phone: "+1234567890",
});

// Stable user_id stamped on every future event (e.g. on login)
setUserId("user_abc123");

// Clear on logout
setUserId(null);
```

`identify` does the heavier identity-graph work (anonymous_id ↔ email ↔ phone resolution). `setUserId` just stamps your application's user ID on events.

## Attribution passthrough (checkout)

When a customer pays through Stripe / MercadoPago, the payment carries no ad signal — so Atribu has to *guess* which visit it belongs to by matching email. Hand the payment the exact identity + ad signal the tracker already holds and the guess becomes a direct match, so the sale ties to the precise ad that drove it.

`getAttribution()` returns the fields; `getAttributionToken()` packs them into one compact string you can drop into a hidden form field, a Stripe `metadata` value, or a `client_reference_id`.

```typescript
import { getAttribution, getAttributionToken } from "@atribu/tracker";

getAttribution();
// {
//   anonymous_id: "anon_…", session_id: "sess_…",
//   utm_source: "facebook", utm_campaign: "1202…", utm_content: "1202…",
//   gclid: "Cj0…", gbraid: "0AAA…", fbclid: "IwAR…", // every captured click id
//   first_touch: { utm: {…}, click_ids: {…}, captured_at: "…" }
// }

getAttributionToken(); // "atb1.eyJhbm9ueW1vdXNfaWQiOiJhbm9uXy4uLiJ9…"
```

**Carry the token into the checkout.** Easiest is a hidden field on the form that starts checkout:

```html
<input type="hidden" name="atribu_attribution" id="atribu_attribution" />
<script>
  document.getElementById("atribu_attribution").value =
    window.atribuTracker.getAttributionToken();
</script>
```

Or attach it to the Stripe payment you create server-side. Put the token on `metadata.atribu_attribution` — on the **Checkout Session**:

```typescript
// client → send both with the checkout request
const attribution = window.atribuTracker.getAttribution();
// 500 = Stripe's metadata value limit: over it, the token sheds first-touch
// detail, then UTMs, then the lowest-priority click ids — never the identity.
const token = window.atribuTracker.getAttributionToken(500);

// server → Stripe Checkout Session
await stripe.checkout.sessions.create({
  // …line_items, mode, success_url…
  client_reference_id: attribution.anonymous_id, // ≤200 chars — id only
  metadata: { atribu_attribution: token },        // ≤500 chars — the full token
});
```

…or directly on a **PaymentIntent** if you build your own payment flow:

```typescript
await stripe.paymentIntents.create({
  amount, currency,
  metadata: { atribu_attribution: token },        // read back on charge.succeeded
});
```

**Stripe Payment Links are zero-config.** Any `buy.stripe.com` Payment Link on a page running the tracker is auto-decorated with `?client_reference_id={anonymous_id}` at click time (an existing `client_reference_id` is never overwritten), so it attributes with no code from you.

**Atribu reads it automatically.** If the paying Stripe account is connected to Atribu, our payment webhook decodes `metadata.atribu_attribution` (or `client_reference_id`), stamps the visitor's `anonymous_id` / session / click-ids onto the cash event, and links it to the exact ad-click session on the next recompute — **no email or phone match required**. You only have to put the token in `metadata` (or use a Payment Link on a tracked page).

**Processing the payment yourself?** Decode it with the same server helper and hand the fields to your ingestion:

```typescript
import { parseAttributionToken } from "@atribu/analytics-enrichment/attribution-token";

const attribution = parseAttributionToken(session.metadata?.atribu_attribution);
// → { anonymous_id, session_id, utm_*, fbclid, gclid, first_touch } | null
```

`parseAttributionToken` returns `null` for a missing/garbled token (fall back to email matching) and ignores any field that isn't part of the contract.

**Webpay (Transbank) needs the short form.** Webpay Plus has no `metadata` field: the only merchant-supplied values it echoes back are `buy_order` (26 chars) and `session_id` (61), and the token above is 80–904 chars. `getAttributionRef()` packs the identity into 48 characters that fit `session_id`, and Atribu recovers the ad signal from the visitor's own session:

```html
<script>
  // Always keep a fallback — Webpay needs a value, and the ref is "" when the
  // visitor's identity cannot be packed.
  const sessionId =
    window.atribuTracker.getAttributionRef() || ("sess-" + orderId);
  // …then send `sessionId` as session_id in Transaction.create(), and POST the
  // commit response to /api/v1/payments/webpay. Full recipe:
  // /integrations/webpay
</script>
```

**MercadoPago** works the same way — put the token on the preference `metadata.atribu_attribution` or `external_reference` when you create the checkout, and Atribu's MP webhook reads it back (no code if the MP account is connected to Atribu). Atribu also recovers the customer name + phone that Shopify-via-MP hides in the payment metadata.

**Reading before the tracker loads.** `getAttribution()` is synchronous once the tracker is on the page. If your checkout script may run first, queue a `ready` callback on the loader stub — it fires with the live tracker:

```html
<script>
  window.atribuTracker = window.atribuTracker || { q: [] };
  window.atribuTracker.q.push(["ready", function (t) {
    document.getElementById("atribu_attribution").value = t.getAttributionToken();
  }]);
</script>
```

Once loaded, `atribuTracker.ready(cb)` and `await atribuTracker.getAttributionAsync()` are the post-init equivalents.

## Confirmation-page purchase

Fire a `purchase` on your thank-you / order-confirmation page. Because it runs on the same device with the live `anonymous_id`/`session_id`, the sale is captured with its ad-click lineage — a same-device backstop for when the payment provider can't carry the attribution token.

```typescript
import { purchase } from "@atribu/tracker";

// on the confirmation page, once the order is confirmed:
purchase({ value: 149.0, currency: "USD", orderId: "ord_1234" });
```

Or with the script-tag global: `window.atribuTracker.purchase({ value, currency, orderId })`.

Pass your own **`orderId`** — it's the key that ties this confirmation to the payment your provider reports. This event does not replace the provider's payment record (that's what carries the cash); it makes sure the ad-click session is captured on the paying device so the payment stitches to the exact ad. Pair it with the checkout token above (or an `identify()` call) for the strongest link.

## Consent management

```typescript
import { setConsent } from "@atribu/tracker";

setConsent({ analytics: true, ads: false });
```

Consent is enforced, not just reported: with `ads: false` no `_fbc`/`_fbp` is read or written, no click id is saved and the event is never exported to Meta, Google or TikTok; with `analytics: false` the visitor id stays in memory for the page. The decision is remembered on the device and attached to every event. Events are not queued waiting for consent. Google Consent Mode v2 and IAB TCF are read automatically — see [Visitor Consent](/docs/tracking/consent).

## Declarative events (no-code)

Track events without writing JavaScript using HTML data attributes:

```html
<button
  data-atribu-event="signup"
  data-atribu-prop-plan="pro"
  data-atribu-revenue="99.99"
  data-atribu-currency="USD"
>
  Sign Up
</button>
```

The tracker auto-captures clicks on elements with `data-atribu-event` and extracts:

- Event name from `data-atribu-event`
- Custom properties from `data-atribu-prop-*` attributes
- Revenue from `data-atribu-revenue` + `data-atribu-currency`

## Privacy

<Callout type="info" title="What the tracker does NOT do">
- No third-party tracking — all data goes to your configured endpoint only
- No cookies set for tracking IDs (visitor + session IDs live in `localStorage`)
- No fingerprinting — visitor IDs are cryptographically random, not derived from device characteristics
</Callout>

- **First-party only.** All data goes to your configured endpoint. No cross-site identifiers.
- **No cookies set for tracking.** Visitor + session IDs live in `localStorage`. The tracker reads `_fbp`/`_fbc` cookies (set by Meta Pixel) only when present, for CAPI match quality.
- **Consent-enforcing.** `setConsent()`, Google Consent Mode v2 and IAB TCF gate what is stored and exported, and the state rides every event.
- **Bot filtering on-device.** Traditional bots are blocked at the client level. AI agents (ChatGPT, Claude, Perplexity, Gemini) are tracked but tagged `visitor_type: "ai_agent"` so you can filter them in reporting.
- **Scheduler redirects are opt-in.** Only with `enableSchedulerIdentity: true` *and* the profile setting on does the tracker read the email / phone / name a scheduler adds to a thank-you link; it then removes them from the address bar and sends them in the request body. Those parameters are scrubbed from every stored URL regardless. See [Scheduler Redirects](/docs/tracking/scheduler-redirects).

## Lifecycle controls

```typescript
import { flush, reset } from "@atribu/tracker";

flush(); // force-send queued events (e.g. before navigating away)
reset(); // clear visitor ID, session, all stored state (e.g. on logout)
```

## Singleton access

Initialize once, retrieve from anywhere:

```typescript
import { init, getTracker } from "@atribu/tracker";

// At app entry:
init({ trackingKey: "trk_live_..." });

// In any other module:
const atribu = getTracker();
atribu.track("button_click", { label: "hero-cta" });
```

## Developer exclusion

Exclude yourself from tracking during development:

```javascript
localStorage.atribu_ignore = "true";
```

Remove it to re-enable:

```javascript
delete localStorage.atribu_ignore;
```

## Runtime support

| Runtime | Supported |
|---|---|
| Modern browsers (Chrome, Safari, Firefox, Edge) | ✅ |
| Mobile webviews (iOS, Android) | ✅ |
| SSR (Node 18+, Next.js server components) | ✅ — no-op fallback |
| Bun (browser bundle) | ✅ |
| Deno | ❌ (no DOM) |

In SSR contexts, `init()` returns a silent no-op client so you can safely import + call from server components.

## Provenance

Every published version of `@atribu/tracker` ships with a Sigstore provenance attestation signed by GitHub Actions OIDC. Verify with:

```bash
npm audit signatures
```

---

# MCP Server

Atribu provides a hosted [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that gives AI tools direct access to your attribution data. Ask questions in natural language and get answers grounded in real campaign performance, customer journeys, and revenue data.

```mermaid
flowchart LR
    A["Claude Code / Cursor"] -->|MCP protocol| B["mcp.atribu.app"]
    B -->|queries| C["Atribu Data"]
    C --> D["Campaigns"]
    C --> E["Journeys"]
    C --> F["Revenue"]
    B -->|structured response| A
```

---

## What you can do

- **"What's my ROAS this month?"** -- get headline KPIs with spend, revenue, and attribution coverage
- **"Which campaigns should I kill?"** -- detect creative fatigue and identify underperformers
- **"Show me the journey for this customer"** -- trace every touchpoint from first click to payment
- **"Compare last week to the week before"** -- period-over-period analysis with delta percentages
- **"Are there any anomalies?"** -- automatic z-score anomaly detection across spend, revenue, and traffic
- **"Send these conversions to Meta"** -- push attributed conversions to Meta CAPI for optimization

---

## How it works

Atribu's MCP server is **centrally hosted** at `https://mcp.atribu.app/mcp`. Your AI tool connects over OAuth -- there is no local installation, no server setup and, for the clients on [Connect an AI client](/docs/mcp/connect-clients), no token to copy either. A personal access token stays available for scripts and CI.

The server exposes **56 tools** that return structured, semantically-labeled data. All reasoning and narration happens on the AI tool's side -- Atribu provides the data, your AI provides the analysis.

<Cards>
<Card title="Quickstart" href="/docs/mcp/quickstart">
  Connect in under 2 minutes
</Card>
<Card title="Connect an AI client" href="/docs/mcp/connect-clients">
  The exact config for Claude, Claude Code, Cursor, ChatGPT and the rest
</Card>
<Card title="Connect to Claude" href="/docs/mcp/connect-claude">
  One-click OAuth connector for Claude Desktop & Claude.ai
</Card>
<Card title="Authentication" href="/docs/mcp/authentication">
  Token scopes, rate limits, and metering
</Card>
<Card title="Available Tools" href="/docs/mcp/tools">
  All 56 tools with parameters and examples
</Card>
<Card title="Write-Back (Meta CAPI)" href="/docs/mcp/write-back">
  Push conversions to Meta with safety rails
</Card>
</Cards>

---

## Supported AI tools

The MCP server works with any tool that supports the Streamable HTTP transport:

| Tool | Transport | Status |
|------|-----------|--------|
| Claude Code (CLI) | Streamable HTTP | Supported |
| Claude Desktop | Streamable HTTP | Supported |
| Cursor | Streamable HTTP | Supported |
| Windsurf | Streamable HTTP | Supported |
| VS Code (Copilot) | Streamable HTTP | Supported |
| ChatGPT (developer mode) | Streamable HTTP | Supported |
| Any MCP-compatible client | Streamable HTTP | Supported |

The exact configuration for each one is on [Connect an AI client](/docs/mcp/connect-clients).

---

## Key design decisions

**No AI on the server.** The MCP server returns structured data with semantic labels, units, and metadata. Your AI tool interprets the data and generates the narrative. This means zero LLM inference cost on Atribu's side and you always get the latest model capabilities from your AI tool.

**Explicit scope per call.** Every tool call specifies which workspace and profile to query. There's no "active workspace" state on the server, so concurrent AI sessions using the same token are safe.

**PII masked by default.** Customer emails, phone numbers, and names are masked unless you explicitly request unmasking with the right token scope and workspace setting.

**Weighted metering.** Tools cost different amounts of units depending on complexity. Listing workspaces is free; sending conversions to Meta costs 10 units. Your plan includes a monthly unit allowance.

---

## Related

<Cards>
  <Card title="REST API" href="/docs/api/quickstart">
    Programmatic access via traditional REST endpoints
  </Card>
  <Card title="Attribution Models" href="/docs/concepts/attribution-models">
    How credit is assigned across touchpoints
  </Card>
  <Card title="Revenue Types" href="/docs/concepts/revenue-types">
    Cash vs pipeline vs gross revenue
  </Card>
</Cards>

---

# Quickstart

The Atribu MCP server speaks OAuth 2.1 with Dynamic Client Registration, so an
agent can connect a human who does **not yet have an Atribu account**. The agent
registers a client, builds an authorization URL and hands it over; the human
signs up or signs in inside that hand-off and approves.

<Callout type="info" title="Claude Desktop, Claude.ai, ChatGPT and Cursor do all of this for you">
Add `https://mcp.atribu.app/mcp` as a **custom connector** and the client runs
the whole flow below on its own — the exact steps per client are on
[Connect an AI client](/docs/mcp/connect-clients).
The steps here are for an agent, script or CI job driving the flow itself.
</Callout>

<Steps>
<Step>

## Register a client and build the authorize URL

```bash title="1. Register (RFC 7591)"
curl -sX POST https://www.atribu.app/oauth/mcp/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My Agent",
    "redirect_uris": ["http://127.0.0.1:7788/callback"],
    "token_endpoint_auth_method": "none",
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"],
    "scope": "mcp:read"
  }'
```

```json title="201 Created"
{ "client_id": "mcpc_9f2c…", "redirect_uris": ["http://127.0.0.1:7788/callback"], "scope": "mcp:read" }
```

Three scopes exist and each is a separate checkbox on the consent screen. Ask
for the least you need:

| scope | what it grants |
|---|---|
| `mcp:read` | analytics — campaigns, creatives, ROAS. PII masked. |
| `mcp:read_pii` | the same, with customer names/emails/phones, where the workspace allows it |
| `mcp:write` | conversion write-back to Meta CAPI, on workspaces where the user is an admin with write-back on |

```python title="2. Authorize URL (PKCE S256 — plain is refused)"
import base64, hashlib, os, urllib.parse

verifier  = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
state = base64.urlsafe_b64encode(os.urandom(16)).rstrip(b"=").decode()

url = "https://www.atribu.app/oauth/mcp/authorize?" + urllib.parse.urlencode({
    "response_type": "code",
    "client_id": "mcpc_9f2c…",
    "redirect_uri": "http://127.0.0.1:7788/callback",
    "scope": "mcp:read",
    "state": state,
    "code_challenge": challenge,
    "code_challenge_method": "S256",
    "resource": "https://mcp.atribu.app/mcp",
})
```

Endpoints are discoverable at
`https://www.atribu.app/.well-known/oauth-authorization-server`, and the
resource server advertises itself at
`https://mcp.atribu.app/.well-known/oauth-protected-resource`.

</Step>
<Step>

## Your agent gives the human a URL. They open it, create their account, approve. They come back.

Print it and wait — this is the human's only step.

- **No Atribu account yet?** The page sends them to sign in; an unrecognised
  email goes straight to signup with the pending authorization preserved. After
  they confirm their email, the confirmation link returns them to **your consent
  screen**, not to a dashboard.
- **The consent screen** shows your `client_name`, one checkbox per scope you
  asked for, and the host behind your `redirect_uri` (loopback is labelled as a
  local application). A user with no workspace yet reads *"You have no workspace
  yet — your agent will create one after you approve."*
- **Approve** redirects to your `redirect_uri` with `code` and your `state`.
  Compare `state` before you use `code`.

Deny, and an expired or malformed request, come back to the same
`redirect_uri` with `error` + `error_description` rather than stranding either
of you on a page — see the error table in the [API quickstart](/docs/api/quickstart).

</Step>
<Step>

## Exchange the code for a token

```bash title="POST /oauth/mcp/token"
curl -sX POST https://www.atribu.app/oauth/mcp/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code=THE_CODE_FROM_THE_CALLBACK \
  -d redirect_uri=http://127.0.0.1:7788/callback \
  -d client_id=mcpc_9f2c… \
  -d code_verifier=THE_VERIFIER_FROM_STEP_1
```

```json title="200 OK"
{
  "access_token": "atb_user_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "atb_refresh_…",
  "scope": "mcp:read"
}
```

Access token: 1 hour. Refresh token: 60 days, **rotating** — persist the new one
on every refresh, because replaying an old one revokes the family.

</Step>
<Step>

## Make your first call

```bash title="whoami — always call this first"
curl -sX POST https://mcp.atribu.app/mcp \
  -H "Authorization: Bearer atb_user_…" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"whoami","arguments":{}}}'
```

It costs zero units and returns the workspaces and profiles the caller can
reach, the default currency, the PII mode and whether write-back is enabled — so
the model stops guessing. **The same token also works on `/api/v1`** since #1084 —
`GET /api/v1/workspaces` is its REST equivalent, and profile-scoped routes want an
explicit `?profile_id=`. See [API authentication](/docs/api/authentication).

</Step>
</Steps>

## Using a static MCP token instead

For a script or CI job that belongs to one person and needs no consent screen,
mint a token by hand:

1. Log in to [Atribu](https://www.atribu.app)
2. Go to **Developer** in the sidebar
3. Switch to the **MCP Tokens** tab
4. Click **New token**, select at least **Read analytics**, and copy it

<Callout type="warn" title="Save your token">
The token starts with `atb_user_` and is shown only once. Store it somewhere secure.
</Callout>

<Tabs items={["Claude Code", "Cursor", "Claude Desktop", "Other"]}>
<Tab value="Claude Code">
```bash title="Terminal"
claude mcp add atribu --transport http \
  https://mcp.atribu.app/mcp \
  --header "Authorization: Bearer atb_user_YOUR_TOKEN"
```
</Tab>
<Tab value="Cursor">

Add to your Cursor MCP settings (`.cursor/mcp.json`):

```json title=".cursor/mcp.json"
{
  "mcpServers": {
    "atribu": {
      "url": "https://mcp.atribu.app/mcp",
      "headers": {
        "Authorization": "Bearer atb_user_YOUR_TOKEN"
      }
    }
  }
}
```
</Tab>
<Tab value="Claude Desktop">

<Callout type="info" title="Recommended: one-click connector">
For Claude Desktop, the simplest path is the OAuth **custom connector** — no token needed. See [Connect an AI client](/docs/mcp/connect-clients). The manual config below is an alternative if you prefer a static token.
</Callout>

Add to your Claude Desktop config (`claude_desktop_config.json`):

```json title="claude_desktop_config.json"
{
  "mcpServers": {
    "atribu": {
      "url": "https://mcp.atribu.app/mcp",
      "headers": {
        "Authorization": "Bearer atb_user_YOUR_TOKEN"
      }
    }
  }
}
```
</Tab>
<Tab value="Other">

Any MCP client that supports Streamable HTTP transport works. Configure it with:

- **URL:** `https://mcp.atribu.app/mcp`
- **Transport:** Streamable HTTP
- **Auth header:** `Authorization: Bearer atb_user_YOUR_TOKEN`

</Tab>
</Tabs>

## Verify the connection

```bash title="Health check"
curl https://mcp.atribu.app/health/live
```

```json title="Response"
{ "status": "ok" }
```

## Example conversation

Here's what a typical interaction looks like in Claude Code:

```
You: What happened to our ad performance last week vs the week before?

Claude: I'll compare the two periods for you.

[calls compare_periods with last week vs prior week]

Your ad performance improved last week:
- Spend: $2,450 → $2,680 (+9.4%)
- Revenue: $8,200 → $11,400 (+39.0%)
- ROAS: 3.35 → 4.25 (+26.9%)
- Conversions: 42 → 58 (+38.1%)

The jump in revenue is driven primarily by "Summer Sale - Retargeting"
which generated $4,200 in attributed revenue at a 6.8x ROAS.

Data as of: 2026-04-14T18:30:00Z | Model: last_touch | Currency: USD
```

<Callout type="info" title="Workspace resolution">
If you belong to multiple workspaces, the AI tool will ask you to specify which one. If you have only one workspace with one profile, it's selected automatically.
</Callout>

## Next steps

<Cards>
  <Card title="Authentication" href="/docs/mcp/authentication">
    Token scopes, rate limits, and usage metering
  </Card>
  <Card title="Available Tools" href="/docs/mcp/tools">
    All 56 tools with parameters and examples
  </Card>
  <Card title="Privacy & PII" href="/docs/mcp/privacy">
    How PII masking works and how to configure it
  </Card>
</Cards>

---

# Connect an AI client

Atribu's MCP server is a **Streamable HTTP** endpoint at one address:

```text title="MCP server URL"
https://mcp.atribu.app/mcp
```

Every recipe on this page is the same address handed to a different client. **None of them carries a credential.** The server speaks OAuth 2.1 with dynamic client registration, so the first time a client calls a tool it sends you to Atribu's own consent screen, where you tick what it may read. A static token is a separate thing, for scripts and CI — see the [Quickstart](/docs/mcp/quickstart).

<Callout type="info" title="The same recipes live in the app">
**Developer → Connected apps → Connect an AI client** shows every config below with a copy button, and lists the clients you have already authorized so you can disconnect one. This page and that panel are generated from the same source, so they cannot disagree.
</Callout>

## Pick your client

<Tabs items={["Claude.ai", "Claude Desktop", "Claude Code", "Cursor", "ChatGPT", "Other"]}>
<Tab value="Claude.ai">

Add Atribu as a custom connector in Claude on the web.

<Steps>
<Step>
Open **Settings → Connectors** (on Pro and Max this is **Customize → Connectors**).
</Step>
<Step>
Click **Add**, then **Add custom connector**.
</Step>
<Step>
Paste the URL and click **Add**:

```text title="MCP server URL"
https://mcp.atribu.app/mcp
```
</Step>
<Step>
Sign in to Atribu in the window that opens and choose the access you grant.
</Step>
</Steps>

<Callout type="info" title="Plans">
Custom connectors using remote MCP are available on the Free, Pro, Max, Team and Enterprise plans. On Free you can have one custom connector. On Team and Enterprise an owner adds it under **Organization settings → Connectors** and members then click **Connect**.
</Callout>

</Tab>
<Tab value="Claude Desktop">

Same connector, in the desktop app. There is **nothing to install** — Claude reaches the server itself, so no `mcp-remote` bridge and no edit to `claude_desktop_config.json`.

<Steps>
<Step>
Open **Settings** (`Ctrl`/`⌘` + `,`), then click **Connectors**.
</Step>
<Step>
Click **Add**, then **Add custom connector**.
</Step>
<Step>
Paste the URL and click **Add**:

```text title="MCP server URL"
https://mcp.atribu.app/mcp
```
</Step>
<Step>
Sign in to Atribu and approve. The connector's tools now appear in the chat's attachment menu.
</Step>
</Steps>

</Tab>
<Tab value="Claude Code">

Register the server once per machine:

```bash title="Terminal"
claude mcp add --transport http atribu https://mcp.atribu.app/mcp
```

The server answers the first call with `401`, which is Claude Code's signal to start the OAuth flow. Run `/mcp` inside Claude Code to sign in, to see the connection, or to clear it. Tokens are stored and refreshed for you.

To share the server with a repository instead, commit a project-scoped `.mcp.json`:

```json title=".mcp.json"
{
  "mcpServers": {
    "atribu": {
      "type": "http",
      "url": "https://mcp.atribu.app/mcp"
    }
  }
}
```

</Tab>
<Tab value="Cursor">

Cursor installs an MCP server from a deep link. Open this one — the console's **Open in Cursor** button is the same link:

```text title="Install link"
cursor://anysphere.cursor-deeplink/mcp/install?name=atribu&config=eyJ1cmwiOiJodHRwczovL21jcC5hdHJpYnUuYXBwL21jcCJ9
```

The `config` parameter is base64 of `{"url":"https://mcp.atribu.app/mcp"}` — the server object, nothing more. It carries **no headers and no token**: Cursor runs the OAuth flow for remote servers itself.

If your browser or OS will not open `cursor://` links, add the same server by hand in `~/.cursor/mcp.json` (or `.cursor/mcp.json` for one project):

```json title="~/.cursor/mcp.json"
{
  "mcpServers": {
    "atribu": {
      "url": "https://mcp.atribu.app/mcp"
    }
  }
}
```

</Tab>
<Tab value="ChatGPT">

ChatGPT reaches custom MCP servers through **developer mode**.

<Steps>
<Step>
Open **Settings → Security and login** and turn on **Developer mode**. Availability depends on your plan and, inside a workspace, on your admin's policy.
</Step>
<Step>
Create a new connection and give it a name and description.
</Step>
<Step>
Under **Connection**, enter the MCP server URL — the `/mcp` path included:

```text title="MCP server URL"
https://mcp.atribu.app/mcp
```
</Step>
<Step>
Create the connection, then review the tools ChatGPT discovered.
</Step>
</Steps>

</Tab>
<Tab value="Other">

Any client that speaks the Streamable HTTP transport works. Point it at the same URL:

```json title="MCP config"
{
  "mcpServers": {
    "atribu": {
      "type": "http",
      "url": "https://mcp.atribu.app/mcp"
    }
  }
}
```

Leave out any `Authorization` header. The client will be sent through Atribu's sign-in on its first call — or, if it cannot open a browser, give it a [personal access token](/docs/mcp/authentication) instead.

</Tab>
</Tabs>

## What the first connection asks for

The client sends you to Atribu's consent screen. It names the client, the account you are signed in as, and one checkbox per scope. Untick anything you do not need — **Read analytics** alone answers every performance question.

| Scope | What it allows | Tick it? |
|-------|----------------|----------|
| **Read analytics** (`mcp:read`) | Attribution, campaigns, creatives, journeys. Personal data masked. | Yes |
| **Read unmasked PII** (`mcp:read_pii`) | Names, emails and phones, when you ask for them | Only if you need it |
| **Write-back** (`mcp:write`) | Onboarding tools and sending conversions to Meta CAPI | Only if you need it |

Ticking a box never bypasses a workspace control: read-PII still requires the workspace to be in full-PII mode, and write-back still requires a workspace admin to have enabled it.

Every connection you approve is listed under **Developer → Connected apps**, with the scopes it holds and a **Disconnect** button that revokes it immediately.

## Troubleshooting

- **The client says it cannot connect.** Check the URL is exactly `https://mcp.atribu.app/mcp`, `/mcp` path included. `curl https://mcp.atribu.app/health/live` should answer `{ "status": "ok" }`.
- **Nothing happens when you open the Cursor link.** The browser asks permission to open Cursor the first time; if the prompt is blocked, use the `~/.cursor/mcp.json` fallback above.
- **The client keeps asking which workspace.** Expected when you belong to more than one — name it in your prompt, or ask the client to list your workspaces first.
- **A tool answers "write-back is not enabled".** The scope is not the whole story: a workspace admin also has to turn write-back on. See [Write-back](/docs/mcp/write-back).
- **You want to start over.** Disconnect under **Developer → Connected apps**, then add the client again.

## Related

<Cards>
  <Card title="Connect to Claude" href="/docs/mcp/connect-claude">
    The Claude connector in depth — consent screen, revocation, plan notes
  </Card>
  <Card title="Authentication" href="/docs/mcp/authentication">
    Scopes, personal access tokens, rate limits
  </Card>
  <Card title="Available Tools" href="/docs/mcp/tools">
    Every tool with parameters and examples
  </Card>
  <Card title="Write-back" href="/docs/mcp/write-back">
    What `mcp:write` can do, and what still gates it
  </Card>
</Cards>

---

# Connect to Claude

If you use **Claude Desktop** or **Claude.ai** (web), the easiest way to connect is as a *custom connector*. You paste the Atribu MCP URL once, sign in, and approve — Claude handles authentication over OAuth. There's no `atb_user_` token to generate, copy, or store.

<Callout type="info" title="Two ways to connect">
- **Claude Desktop / Claude.ai** → add Atribu as a **custom connector** (this page). One-click OAuth, no token.
- **Claude Code, Cursor, ChatGPT** → they connect over OAuth too, each with its own recipe. See [Connect an AI client](/docs/mcp/connect-clients).
- **Scripts, CI, anything with nobody there to approve** → use a **personal access token**. See the [Quickstart](/docs/mcp/quickstart).
</Callout>

## Add the connector

<Tabs items={["Claude Desktop", "Claude.ai (web)"]}>
<Tab value="Claude Desktop">

<Steps>
<Step>
Open **Settings → Connectors**.
</Step>
<Step>
Click **Add custom connector**.
</Step>
<Step>
Paste the Atribu MCP URL and confirm:

```text title="MCP server URL"
https://mcp.atribu.app/mcp
```
</Step>
<Step>
A sign-in window opens. **Log in to Atribu** (the same account you use at [atribu.app](https://www.atribu.app)).
</Step>
<Step>
Review the access being requested, choose your scopes, and click **Allow**. Claude is now connected.
</Step>
</Steps>

</Tab>
<Tab value="Claude.ai (web)">

<Steps>
<Step>
Open **Settings → Connectors**.
</Step>
<Step>
Click **Add custom connector**.
</Step>
<Step>
Paste the Atribu MCP URL:

```text title="MCP server URL"
https://mcp.atribu.app/mcp
```
</Step>
<Step>
**Log in to Atribu** in the window that opens, then click **Allow** on the consent screen.
</Step>
</Steps>

<Callout type="info" title="Plan requirement">
Custom connectors on Claude.ai are available on Claude's paid plans. Exact menu labels may differ slightly between Claude versions.
</Callout>

</Tab>
</Tabs>

## The consent screen

Before connecting, Atribu shows you exactly what Claude is asking for: the account you're signed in as, the scopes requested, and that the connection covers **all workspaces you belong to**.

Grant only the scopes you actually need:

| Scope | What it allows | Recommended |
|-------|----------------|-------------|
| **Read analytics** (`mcp:read`) | Attribution, campaigns, creatives, journeys — personal data masked | ✅ For most users |
| **Read unmasked PII** (`mcp:read_pii`) | Unmask email/phone/name when you explicitly ask | Only if you need it |
| **Write-back** (`mcp:write`) | Set the workspace up and send conversions to Meta CAPI (the onboarding tools, `send_meta_conversions`) | Only if you need it |

<Callout type="info" title="Leave PII and write-back unchecked unless you need them">
If Claude requests all scopes, you can uncheck the ones you don't want before clicking **Allow** — **Read analytics** alone is enough to ask about performance. Read-PII still requires your workspace to be in full-PII mode, and Write-back still requires a workspace admin to enable it, so checking them never bypasses your workspace's own controls.
</Callout>

## Try it out

Open a chat in Claude and ask about your ad performance:

```text
What's my ROAS this month? Which campaigns are performing best?
```

Claude calls the right Atribu tools and answers from your real data. If you belong to multiple workspaces, it will ask which one to use.

## Manage connected apps

Every connection is listed in **Developer → Connected Apps** in the Atribu dashboard. There you can see which apps you've authorized, the scopes they hold, and when they were last used.

To revoke access, click **Disconnect** — the app loses access immediately. It can reconnect later by authorizing again.

<Callout type="info" title="Connector vs. token revocation">
Disconnecting a connector here revokes the OAuth grant (and the short-lived tokens it issued). Personal access tokens are managed separately under **Developer → MCP Tokens**.
</Callout>

## When to use a token instead

The custom connector is best for interactive use in Claude Desktop and Claude.ai. **Claude Code and Cursor also connect over OAuth** — they run the flow themselves, so neither needs a token either; their exact recipes are on [Connect an AI client](/docs/mcp/connect-clients).

Create a [personal access token](/docs/mcp/authentication) for the case that is left:

- **Scripts, automations, CI** — anywhere there's no interactive sign-in

## Troubleshooting

- **The sign-in window doesn't appear, or Claude says it can't connect.** Double-check the URL is exactly `https://mcp.atribu.app/mcp` (with the `/mcp` path). Confirm the server is reachable: `curl https://mcp.atribu.app/health/live` should return `{ "status": "ok" }`.
- **You can't find "Add custom connector".** On Pro and Max the menu is **Customize → Connectors**; on Team and Enterprise an owner adds it under **Organization settings → Connectors** and members then click **Connect**. Custom connectors are available on Free, Pro, Max, Team and Enterprise, with one connector on Free.
- **Claude keeps asking which workspace.** That's expected if you belong to more than one — name the workspace in your prompt, or ask Claude to "list my workspaces" first.
- **You want to start over.** Disconnect under **Developer → Connected Apps**, then add the connector again.

## Related

<Cards>
  <Card title="Authentication" href="/docs/mcp/authentication">
    Scopes, rate limits, and personal access tokens
  </Card>
  <Card title="Available Tools" href="/docs/mcp/tools">
    Every tool with parameters and examples
  </Card>
  <Card title="Privacy & PII" href="/docs/mcp/privacy">
    How PII masking works and how to configure it
  </Card>
</Cards>

---

# Authentication

<Callout type="info" title="Two ways to authenticate">
- **OAuth custom connector** — for Claude.ai, Claude Desktop, Claude Code, Cursor and ChatGPT. One-click sign-in, no token to manage. See [Connect an AI client](/docs/mcp/connect-clients).
- **Personal access token** — for Claude Code, Cursor, scripts, and CI. Covered on this page.

Both produce a short Bearer token the MCP server validates; the difference is whether *you* manage it (token) or Claude does (connector). Connector access tokens are short-lived — they expire after 1 hour and Claude refreshes them automatically with a 60-day refresh token.
</Callout>

All MCP requests carry a Bearer token in the `Authorization` header — created by you as a personal access token, or issued automatically by the OAuth connector flow.

```bash title="Authorization header"
Authorization: Bearer atb_user_your_token_here
```

MCP tokens are **user-scoped** -- a single token grants access to every workspace you belong to. The workspace and profile are specified per tool call, not stored on the token.

## Creating a token

Create tokens from **Developer > MCP Tokens** in the Atribu dashboard. You can have up to **10 active tokens** per user — revoke unused ones to free a slot.

<Callout type="warn" title="Show once">
Tokens are shown exactly once at creation. Store them securely -- you cannot retrieve them later.
</Callout>

## Scopes

Each token has granular scopes that control what data and actions are available.

| Scope | Access |
|-------|--------|
| `mcp:read` | Attribution data, campaigns, creatives, journeys (PII masked) |
| `mcp:read_pii` | Unmask email, phone, and full name when `include_sensitive=true` |
| `mcp:write` | Every write: create workspaces and profiles, mint tracking keys, start connect hand-offs, define conversions, configure Conversion Sync, and export conversions to Meta CAPI |

**Default scope** when creating a token: `mcp:read`.

<Callout type="error" title="Write scope">
`mcp:write` lets an agent configure your attribution AND send conversion data to external platforms — since [#1058](https://github.com/AtribuCore/atribu/issues/1058) it maps to `exports:read` + `exports:write` as well as the attribution-write set, because configuring a CAPI destination is what the onboarding tools do. Two things still gate it beyond the grant: a workspace admin must enable write-back in workspace settings, and the caller must hold an owner/admin role. Reads and every write tool's `preview` mode work without any of it.
</Callout>

## Rate limits

MCP uses **weighted rate limiting**. Each tool has a cost in units, and your token has a per-minute burst cap.

**Per-minute cap:** 120 units (default).

| Tool | Cost |
|------|------|
| `whoami`, `list_workspaces`, `list_profiles` | 0 units (free) |
| `explain_campaign`, `explain_customer_journey`, `creative_fatigue_check`, `find_anomalies`, `whatsapp_attribution_summary` | 2 units |
| `compare_attribution_models` | 5 units |
| `send_meta_conversions`, `apply_recommendation` | 10 units |
| Every other tool (performance, campaign, traffic, engagement, list, workspace creative-intelligence, and recommendation reads) | 1 unit |

When rate limited, the tool returns a `rate_limited` error with `retry_after` (seconds) and `retryable: true`.

## Usage metering

Your subscription includes a monthly unit allowance. Units are committed when the call is admitted — before the tool runs — so a call that fails mid-execution still counts against your period allowance.

| Plan | Units per period |
|------|-----------------|
| Trial | 2,000 / 14 days |
| Starter | 20,000 / month |
| Growth | 200,000 / month |
| Agency | 2,000,000 / month |
| Enterprise | Unlimited |

Check your current usage from the **Developer > MCP Tokens** tab in the dashboard.

## Token management

### Rotation

Rotate tokens with zero downtime:

<Steps>
<Step>
Click **Rotate** next to the token in the dashboard
</Step>
<Step>
A new token is generated and revealed once
</Step>
<Step>
Update your AI tool configuration with the new token
</Step>
<Step>
The old token keeps working for a **48-hour grace period**, then is retired automatically
</Step>
</Steps>

### Revocation

Click **Revoke** to immediately invalidate a token. Any AI tool using that token will lose access instantly.

## Concurrent sessions

Tokens have no server-side state. Multiple AI tools or IDE sessions can use the same token simultaneously without interfering with each other. Each tool call specifies its own workspace and profile scope.

## Related

<Cards>
  <Card title="Quickstart" href="/docs/mcp/quickstart">
    Connect in under 2 minutes
  </Card>
  <Card title="Error Reference" href="/docs/mcp/errors">
    Error codes and troubleshooting
  </Card>
  <Card title="Privacy & PII" href="/docs/mcp/privacy">
    How PII masking and workspace settings work
  </Card>
</Cards>

---

# Available Tools

The Atribu MCP server exposes 56 tools. Every tool returns a structured response with a `data` object and a `meta` object containing request metadata, data freshness, and attribution context.

Most of them READ. The [Onboarding tools](#onboarding-tools) are the exception: they set a workspace up from nothing, and each one is a thin wrapper on a single `/api/v1` route that forwards your own token — so anything an agent can do here, a REST client can do too, under the same gates.

## Shared parameters

Most tools accept these common parameters:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `workspace_id` | UUID | No | Which workspace to query. Inferred if you have exactly one. |
| `profile_id` | UUID | No | Which profile to query. Inferred if the workspace has exactly one. |
| `window_start` | `YYYY-MM-DD` | Yes | Start of the date range |
| `window_end` | `YYYY-MM-DD` | Yes | End of the date range |
| `model` | string | No | Attribution model. Defaults to the profile's configured model — the one its dashboard uses. |

**Available attribution models:** `last_touch`, `first_touch`, `linear`, `time_decay`, `last_non_direct`

**The default is the profile's own setting, not `last_touch`.** Omit `model` and the tool resolves whatever that profile is configured with, so an answer from an agent matches the number on the dashboard for the same question. Pass `model` explicitly only when you mean to compare — `compare_attribution_models` is built for that, and reports `profile_configured_model` so you can say which row the customer is looking at.

Every response reports the model it actually used, in `meta.attribution_model` and as `attribution_model` in the payload. Quote it when you narrate the number.

**Two scope grains.** Most tools are **profile-scoped**: they operate on one profile (`workspace_id` + `profile_id`, both inferred when unambiguous). The workspace creative-intelligence and recommendation tools below are **workspace-scoped**: they take only `workspace_id` and read across every profile you can access in that workspace. Workspace-scoped tools are marked with **Scope: workspace**. Instead of `window_start`/`window_end`, they use a pre-aggregated `score_window` (`7d` | `14d` | `28d` | `lifetime`, default `28d`).

---

{/* BEGIN GENERATED TOOL TABLE — generated by scripts/generate-mcp-tool-docs.ts from services/mcp-server/src/tools/registry.ts. Do not hand-edit between these markers; run `npx tsx scripts/generate-mcp-tool-docs.ts`. */}

## Tool reference

Every registered tool, generated from `registry.ts` so this table cannot drift from what the server actually serves. Scope, unit cost and the [MCP annotations](https://modelcontextprotocol.io/specification) a generic host uses to decide whether a call needs confirmation. See each tool's own section below for its parameters and return shape.

| Tool | Scope | Cost | Read-only | Destructive | Required providers |
|------|-------|------|-----------|-------------|---------------------|
| `list_workspaces` | profile | 0 | Yes | No | — |
| `list_profiles` | profile | 0 | Yes | No | — |
| `whoami` | profile | 0 | Yes | No | — |
| `get_performance_summary` | profile | 1 | Yes | No | — |
| `compare_periods` | profile | 1 | Yes | No | — |
| `top_campaigns` | profile | 1 | Yes | No | — |
| `top_ad_sets` | profile | 1 | Yes | No | meta_ads |
| `top_creatives` | profile | 1 | Yes | No | meta_ads |
| `top_workspace_performers` | workspace | 1 | Yes | No | — |
| `top_workspace_creative_patterns_v2` | workspace | 1 | Yes | No | — |
| `top_workspace_archetype_summary` | workspace | 1 | Yes | No | — |
| `top_workspace_experiments` | workspace | 1 | Yes | No | — |
| `top_workspace_fatigue_risk` | workspace | 1 | Yes | No | — |
| `ad_funnel_diagnosis` | profile | 1 | Yes | No | — |
| `get_funnel` | profile | 1 | Yes | No | — |
| `get_tracking_breakdown` | profile | 1 | Yes | No | — |
| `get_attribution_quality` | profile | 1 | Yes | No | — |
| `get_cash_attribution_coverage` | profile | 1 | Yes | No | — |
| `list_conversions` | profile | 1 | Yes | No | — |
| `list_visitors` | profile | 1 | Yes | No | — |
| `explain_campaign` | profile | 2 | Yes | No | — |
| `explain_customer_journey` | profile | 2 | Yes | No | — |
| `compare_attribution_models` | profile | 5 | Yes | No | — |
| `creative_fatigue_check` | profile | 2 | Yes | No | meta_ads |
| `find_anomalies` | profile | 2 | Yes | No | — |
| `whatsapp_attribution_summary` | profile | 2 | Yes | No | whatsapp |
| `send_meta_conversions` | profile | 10 | No | Yes | meta_ads |
| `list_workspace_recommendations` | workspace | 1 | Yes | No | — |
| `diagnose_recommendation` | workspace | 1 | Yes | No | — |
| `apply_recommendation` | workspace | 10 | No | Yes | meta_ads |
| `top_dm_ads` | profile | 1 | Yes | No | — |
| `experiment_power_calc` | workspace | 1 | Yes | No | — |
| `workspace_historical_lift_band` | workspace | 1 | Yes | No | — |
| `workspace_pattern_gaps` | workspace | 1 | Yes | No | — |
| `generate_creative_brief` | profile | 5 | Yes | No | — |
| `get_readiness` | profile | 1 | Yes | No | — |
| `start_plan_upgrade` | workspace | 10 | No | No | — |
| `create_workspace` | profile | 5 | No | No | — |
| `create_profile` | workspace | 5 | No | No | — |
| `create_demo_profile` | workspace | 5 | No | No | — |
| `delete_demo_profile` | workspace | 1 | No | Yes | — |
| `issue_tracking_key` | profile | 1 | No | No | — |
| `get_tracker_installer` | profile | 1 | Yes | No | — |
| `start_connect` | profile | 1 | No | No | — |
| `get_handoff` | profile | 0 | Yes | No | — |
| `list_outcome_events` | profile | 2 | Yes | No | — |
| `suggest_conversion_definitions` | profile | 2 | Yes | No | — |
| `create_conversion_definition` | profile | 2 | No | No | — |
| `set_attribution_windows` | profile | 2 | No | No | — |
| `configure_meta_capi` | profile | 2 | No | No | — |
| `send_test_event` | profile | 10 | No | Yes | — |
| `sign_dpa` | profile | 1 | No | No | — |
| `pause_campaign` | profile | 2 | No | No | meta_ads |
| `resume_campaign` | profile | 2 | No | No | meta_ads |
| `list_api_operations` | profile | 1 | Yes | No | — |
| `call_api_operation` | profile | 1 | Yes | No | — |

{/* END GENERATED TOOL TABLE */}

---

## Discovery tools

### whoami

Identity, usage, and effective settings for the current MCP token. Returns scopes, units used vs cap, your workspaces, and (when scope is unambiguous) active workspace settings (`pii_mode`, `mcp_writeback_enabled`, your role) and the active profile's currency. Always call once per session — it tells you upfront whether PII unmask or write-back will work, so the AI tool can guide the user correctly without trial-and-error calls.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `workspace_id` | UUID | No | Optional. If passed (or you have one workspace), `active_workspace` is populated. |
| `profile_id` | UUID | No | Optional. If passed (or workspace has one profile), `active_profile` is populated. |

**Cost:** 0 units

**Returns:** `{ token, usage, user, workspaces[], active_workspace, active_profile, server_version }`. `active_workspace` and `active_profile` are `null` when scope is ambiguous.

---

### list_workspaces

List all workspaces you have access to. Use the returned `id` values as `workspace_id` in subsequent calls.

| Parameter | Type | Required |
|-----------|------|----------|
| — | — | — |

**Cost:** 0 units

**Returns:** Array of `{ id, name, role }` objects.

---

### list_profiles

List all profiles within a workspace. Use the returned `id` values as `profile_id` in subsequent calls.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `workspace_id` | UUID | No | Inferred if you have one workspace |

**Cost:** 0 units

**Returns:** Array of `{ id, name, workspace_id }` objects.

---

### get_readiness

Setup checklist for one profile: which steps of the golden path are done, blocked or not started, WHY each is in that state, and the exact next call or hand-off to unblock it. **Read this before concluding that a profile has no data** — an empty dashboard is usually a missing connection, not a missing result.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `workspace_id` | UUID | No | Inferred if you have one workspace |
| `profile_id` | UUID | No | Inferred if the workspace has one profile |

**Cost:** 1 unit

**Returns:** `{ steps[], summary: { done, total, next_step }, wizard_finished }`. Each step is `{ key, status, why, next, docs_url }`, where `status` is one of `done` / `missing` / `degraded` / `blocked` and `next` is either `{ method, path }` (a call you can make now) or `{ handoff_kind }` (something only a person can do: `connect`, `sign_dpa`, `checkout`, `contact_support`). Only `done` counts toward `summary.done`.

<Callout type="info" title="This tool calls the REST API as you">
`get_readiness` is a thin wrapper on `GET /api/v1/profile/readiness` (#1084). It forwards **your own** token rather than running on Atribu's service role, so tenancy and scope are decided once, by the API — the same answer you would get calling that endpoint directly. It needs `mcp:read`. See [API authentication](/docs/api/authentication) for the MCP-scope → API-scope map.
</Callout>

---

## Performance tools

### get_performance_summary

Headline KPIs for a date window plus the equivalent prior period. Returns three buckets: **cash** (real revenue, drives ROAS), **pipeline** (CRM deal counts + raw `value_amount` sum — NOT revenue), and **leads** (top of funnel).

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |
| `model` | string | No | the profile's configured model |

**Cost:** 1 unit

**Returns:**

```json title="Example response (data, current_period)"
{
  "spend": { "amount": 1032.42 },
  "cash": {
    "attributed_revenue": { "amount": 4000 },
    "attributed_conversions": 6,
    "total_conversions": 17,
    "collected": { "amount": 9749 },
    "roas": { "value": 3.87, "formatted": "3.87x" }
  },
  "pipeline": {
    "appointments_booked": 38,
    "closed_won": 0,
    "crm_value_amount_sum": {
      "attributed": { "amount": 76600 },
      "total": { "amount": 79600 }
    },
    "note": "Counts are concrete events. crm_value_amount_sum is the raw sum of whatever the agency entered in their CRM's deal value field — NOT revenue. Never present as a revenue claim without confirming the CRM convention."
  },
  "leads": { "count": 409, "note": "Top of funnel events with no revenue." },
  "traffic": { "visitors": 1550, "pageviews": 2386, "bounce_rate_percent": 87.3 }
}
```

<Callout type="warn" title="Pipeline crm_value_amount_sum is NOT revenue">
The CRM `value_amount` field means whatever the agency configured it to mean — deal target, customer lifetime value estimate, retainer size, or arbitrary. Use **counts** (`appointments_booked`, `closed_won`) as the primary signal. Only mention the sum with explicit context. **Never include `crm_value_amount_sum` in ROAS calculations or call it "revenue" or "projected revenue".**
</Callout>

---

### compare_periods

Compare two arbitrary date windows side by side. Useful for year-over-year, pre/post campaign launch, or seasonal comparisons.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |
| `prev_window_start` | date | Yes | — |
| `prev_window_end` | date | Yes | — |
| `model` | string | No | the profile's configured model |

**Cost:** 1 unit

**Returns:** Same structure as `get_performance_summary` with `current` and `previous` objects representing the two windows.

---

## Campaign tools

### top_campaigns

Top campaigns ranked by attributed revenue within a date window.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |
| `model` | string | No | the profile's configured model |
| `limit` | number | No | `10` (max 50) |

**Cost:** 1 unit

**Returns:** Array of campaigns with `name`, `platform_id`, `spend`, `revenue`, `roas`, `conversions`, `clicks`, `impressions`.

<Callout type="info" title="platform_id">
Each campaign includes a `platform_id` which you can pass to `explain_campaign` for a deep dive.
</Callout>

---

### top_ad_sets

Top ad sets ranked by attributed cash revenue. Sits between `top_campaigns` (broader) and `top_creatives` (narrower) — answers "which audience/placement is delivering?" before drilling into individual creatives.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |
| `model` | string | No | the profile's configured model |
| `limit` | number | No | `10` (max 50) |

**Cost:** 1 unit

**Requires:** Meta Ads connection

**Returns:** Array of ad sets with `ad_set_name`, `campaign_name` (parent), `spend`, `attributed_revenue`, `roas`, `attributed_conversions`, `cac`, `ctr_percent`, `impressions`, `clicks`, `avg_engagement_score`.

---

### top_creatives

Top individual ads ranked by ROAS, with creative details (thumbnail URLs, headlines, body text).

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |
| `model` | string | No | the profile's configured model |
| `limit` | number | No | `10` (max 50) |

**Cost:** 1 unit

**Requires:** Meta Ads connection

**Returns:** Array of ads with `name`, `spend`, `revenue`, `roas`, `ctr`, `thumbnail_url`, `headline`, `body`.

---

### explain_campaign

Deep dive into a single campaign: headline metrics, daily trend, and constituent ads.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `campaign_id` | string | Yes | — |
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |
| `model` | string | No | the profile's configured model |

**Cost:** 2 units

The `campaign_id` can be a UUID (internal ID), a Meta `platform_id`, or a campaign name. The tool resolves it automatically.

**Returns:**

```json title="Example response (data)"
{
  "summary": {
    "name": "Summer Sale - Retargeting",
    "platform_id": "23851234567890",
    "spend": 1200.00,
    "revenue": 5400.00,
    "roas": 4.50,
    "conversions": 28,
    "clicks": 1840,
    "impressions": 42000
  },
  "daily_trend": [
    { "date": "2026-04-01", "spend": 180, "revenue": 720, "clicks": 260 }
  ],
  "ads": [
    { "name": "Video - Testimonial", "spend": 600, "revenue": 3200, "roas": 5.33 }
  ]
}
```

---

## Customer tools

### explain_customer_journey

Full event timeline for a single customer: ad clicks, page views, form fills, conversions, and payments.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `customer_profile_id` | UUID | Yes | — |
| `include_sensitive` | boolean | No | `false` |
| `cursor_time` | string | No | — |
| `cursor_id` | UUID | No | — |
| `limit` | number | No | `50` (max 100) |

**Cost:** 2 units

Pagination is cursor-based: pass the `cursor_time` and `cursor_id` from the previous response's `next_cursor` to get the next page. There is no date-window filter — the tool walks the customer's full timeline.

**Returns:** Customer info (name, masked email/phone) plus an array of timeline events ordered chronologically.

<Callout type="info" title="PII masking">
By default, email and phone are masked (e.g. `j***@e****.com`). To see unmasked values, pass `include_sensitive: true` -- requires `mcp:read_pii` scope and workspace PII mode set to `full_default`. See [Privacy & PII](/docs/mcp/privacy).
</Callout>

---

## Analysis tools

### compare_attribution_models

Run the same date window through multiple attribution models to see how credit distribution shifts.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |
| `models` | string[] | No | `["last_touch", "first_touch", "linear", "time_decay", "last_non_direct"]` |
| `top_campaigns_limit` | number | No | `5` (max 20) |

**Cost:** 5 units

**Returns:** One result set per model, each with the same campaign-level metrics. Compare to understand which channels are undervalued by single-touch models.

---

### creative_fatigue_check

Which ads are wearing out («desgaste») — the one degradation rule the product uses: CTR down 30% or more (or cost per result up by the same) for 3 days in a row, versus the ad's own previous 28 days. Reads the rule's own events; it is an observed delivery fact, not a prediction.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |
| `model` | string | No | accepted and ignored (the rule reads delivery, not attribution) |

**Cost:** 2 units

**Requires:** Meta Ads connection

**Returns:** `degraded_ads` — each ad whose degradation began in the window, with `degraded_since`, the legs that fired, and `rule: { id, thresholds, inputs }` (recent vs baseline CTR / cost per result). State the rule with its numbers when you answer.

---

### find_anomalies

Flag unusual daily spikes or drops in spend, revenue, or traffic using z-score analysis (median + MAD).

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |
| `metrics` | string[] | No | `["spend", "cash_revenue", "visitors"]` |
| `threshold_sigma` | number | No | `2.0` |
| `model` | string | No | the profile's configured model |

**Cost:** 2 units

**Returns:** Array of anomalous days with the metric name, observed value, expected range, and z-score magnitude.

---

### get_funnel

Goal-driven conversion funnel for the date window. Walks the canonical lead-gen progression `lead_created → appointment_booked → showed → qualified → closed_won` with per-stage counts, stage-to-stage conversion rates, and drop-off counts. Also returns negative-outcome buckets (`disqualified`, `no_show`, `closed_lost`).

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |
| `scope` | `all` \| `pipeline_only` \| `contact_only` | No | `all` |

**Cost:** 1 unit

`scope=pipeline_only` restricts to events linked to a CRM pipeline (deals in a stage). `scope=contact_only` restricts to events without one (lead-only / form fills).

**Returns:**

```json title="Example response (data)"
{
  "scope": "all",
  "window": { "start": "2026-04-01", "end": "2026-04-30" },
  "main_chain": [
    { "key": "lead_created", "label": "Leads", "count": 409, "conversion_rate_from_previous_pct": null, "drop_off_from_previous_count": 0 },
    { "key": "appointment_booked", "label": "Booked", "count": 38, "conversion_rate_from_previous_pct": 9.29, "drop_off_from_previous_count": 371 },
    { "key": "showed", "label": "Showed", "count": 24, "conversion_rate_from_previous_pct": 63.16, "drop_off_from_previous_count": 14 },
    { "key": "qualified", "label": "Qualified", "count": 12, "conversion_rate_from_previous_pct": 50.00, "drop_off_from_previous_count": 12 },
    { "key": "closed_won", "label": "Closed Won", "count": 0, "conversion_rate_from_previous_pct": 0, "drop_off_from_previous_count": 12 }
  ],
  "drop_off_outcomes": [
    { "key": "disqualified", "label": "Disqualified", "count": 7 },
    { "key": "no_show", "label": "No Show", "count": 5 },
    { "key": "closed_lost", "label": "Closed Lost", "count": 3 }
  ],
  "totals": {
    "all_events": 498,
    "leads": 409,
    "closed_won": 0,
    "lead_to_closed_won_rate_pct": 0
  }
}
```

<Callout type="info" title="Counts are events, not unique customers">
One customer can appear at multiple stages. Don't compute customer-level rates from these event counts. Stage-to-stage conversion rates assume customers progress linearly — in reality customers can skip stages or be re-classified.
</Callout>

<Callout type="warn" title="Stages only show events that are also conversions">
Counts include only events whose `event_key` is mapped under **Settings → Outcomes → Conversion Definitions**. If a CRM stage like `disqualified` shows 0 but your team uses it (e.g. labels "Descualificado", "Lead Abandonado"), the conversion definition is missing. Map the key in Conversion Definitions to make those events appear in the funnel.
</Callout>

---

### get_tracking_breakdown

Top values per traffic-dimension (channel, country, page, browser, device, OS, referrer, campaign, etc.) ranked by visitors. Returns up to 6 dimensions in one call.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |
| `dimensions` | string[] | No | `["channel", "country", "page", "browser"]` |
| `limit` | number | No | `10` (max 50) |

**Available dimensions:** `channel`, `referrer`, `campaign`, `page`, `entry_page`, `exit_page`, `country`, `region`, `city`, `device`, `browser`, `os`

**Cost:** 1 unit

**Returns:**

```json title="Example response (data)"
{
  "breakdowns": {
    "channel": [
      { "dimension_value_key": "paid_social", "label": "Paid Social", "visitors": 820, "visits": 1120, "pageviews": 2400, "bounce_rate_pct": 62.4, "conversions": 14, "conversion_rate_pct": 1.71, "cash_revenue": { "amount": 3200 } }
    ],
    "country": [
      { "dimension_value_key": "US", "label": "United States", "visitors": 540, "visits": 720, "pageviews": 1820, "bounce_rate_pct": 58.1, "conversions": 12, "conversion_rate_pct": 2.22, "cash_revenue": { "amount": 2800 } }
    ]
  },
  "window": { "start": "2026-04-01", "end": "2026-04-30" },
  "dimensions_requested": ["channel", "country"],
  "limit_per_dimension": 10
}
```

<Callout type="info" title="Revenue is cash only">
The `cash_revenue` field uses `revenue_type='cash'` only — same definition the dashboard uses for ROAS. Pipeline / CRM deal values are NOT included here.
</Callout>

---

### list_conversions

Paginated list of conversions for a date window, ordered by `conversion_time DESC`. Each row carries the customer (masked PII by default), first-touch channel, time-to-complete, revenue, plus the customer's lifetime totals.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |
| `goal` | string | No | `payment_received` |
| `search` | string | No | — |
| `cursor_time` | string | No | — |
| `cursor_id` | string | No | — |
| `limit` | number | No | `25` (max 100) |
| `include_sensitive` | boolean | No | `false` |

**Cost:** 1 unit

`goal` filters by `event_type` (e.g. `payment_received`, `lead_created`, `appointment_booked`, `closed_won`). Pass `all` to include every conversion type. Use `next_cursor` from the response for the next page.

For a SINGLE customer's full event timeline, use [`explain_customer_journey`](#explain_customer_journey) instead.

---

### list_visitors

Paginated visitor roster (identified + anonymous) for a date window, ordered by `last_seen_at DESC`. Each row carries last-seen timestamp, session count, total cash revenue, channel/source from the latest session, and device/geo.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |
| `search` | string | No | — |
| `cursor_time` | string | No | — |
| `cursor_id` | string | No | — |
| `limit` | number | No | `25` (max 100) |
| `include_sensitive` | boolean | No | `false` |

**Cost:** 1 unit

`is_identified=true` means the visitor has a `customer_profile_id` (matched via `identify()` or a server-side event). False means anonymous-only. `total_revenue` is cash only (`revenue_type='cash'`) and reflects the customer's lifetime sum.

---

### get_attribution_quality

Diagnostic — how trustworthy is attribution data for this date window? Returns the share of conversion-related events with full UTMs (best), with `fbclid` only (Meta click but no UTMs), and with no tracking at all (worst).

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |

**Cost:** 1 unit

**Returns:**

```json title="Example response (data)"
{
  "window": { "start": "2026-04-01", "end": "2026-04-30" },
  "totals": {
    "total_events": 409,
    "with_full_utms": 248,
    "with_fbclid_only": 102,
    "with_no_tracking": 59
  },
  "percentages": {
    "full_utms_pct": 60.6,
    "fbclid_only_pct": 24.9,
    "no_tracking_pct": 14.4,
    "attributable_coverage_pct": 85.5
  }
}
```

<Callout type="info" title="Heuristic">
`attributable_coverage_pct` above 80% is healthy. Between 50–80% means attribution numbers should be treated cautiously. Below 50% indicates UTM tagging gaps — recommend an audit before trusting campaign-level reports.
</Callout>

---

### get_cash_attribution_coverage

Honesty check: what share of this profile's CASH actually traces to a specific ad? Splits cash conversions into mutually-exclusive buckets by count and value — `ad_attributed` (credited to a navigable ad), `inherited` (recovered via the lead→cash bridge — real ad-driven cash, but a distinct, lower-confidence tier), `dangling_ad` (credited to an ad id that resolves to no known ad — a fixable tracking gap), `organic_direct` (has touches but no ad signal), `unattributed` (no ad touch at all — the dark cash).

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |
| `model` | string | No | Profile's default model |

**Cost:** 1 unit

**Returns:** `total` and `buckets.{ad_attributed,inherited,dangling_ad,organic_direct,unattributed}` (each `{count, value}`, `value` an exact decimal string), `traceable_pct_by_count`, `traceable_pct_by_value`, `notes[]` explaining each bucket.

<Callout type="info" title="A fourth kind of number">
This is a COVERAGE measure — distinct from `composite_score`, `top_performer_likelihood`, and `attributed_revenue`/`roas` (see `top_workspace_performers` below). It is neither a score, a probability, nor ROAS. Call it before trusting ROAS if cash coverage looks low: a low `traceable_pct` means either strong organic performance OR broken checkout instrumentation, and the two demand opposite responses. `inherited` cash is real ad-driven revenue but is never counted in `traceable_pct` or ROAS — it is a separate, lower-confidence tier.
</Callout>

---

### ad_funnel_diagnosis

Per-ad funnel diagnosis — "where in this ad's funnel is the problem?" Returns one row per funnel layer (`delivery → attention → retention → click_intent → postclick_messaging → attributed_revenue → platform_diagnostics`, the same layers the composite score is built from).

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `profile_id` | UUID | No | Inferred if the workspace has exactly one |
| `ad_external_id` | string | Yes | — |
| `score_window` | `7d` \| `14d` \| `28d` \| `lifetime` | No | `28d` |

**Cost:** 1 unit

**Returns:** Per layer: the ad's `percentile` within its cohort, the underlying `smoothed_metric`, `sample_n`, `is_weakest_layer` (the bottleneck — lowest percentile when below the 25th), machine-readable `diagnostic_codes[]`, and a `recommended_priority` — one of `fix_offer_or_audience`, `fix_landing_or_offer`, `fix_hook`, `improve_creative_clarity`, `increase_spend`, `rework_offer_or_landing`, or `maintain`.

<Callout type="info" title="Daily freshness">
`meta.data_as_of` reflects the daily creative-score rebuild, not the connector sync time — funnel percentiles update once per day even when Meta data synced minutes ago.
</Callout>

---

## Messaging tools

### whatsapp_attribution_summary

Revenue and conversions attributed to WhatsApp touchpoints (Click-to-WhatsApp ads or WhatsApp-sourced traffic).

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |

**Cost:** 2 units

**Requires:** WhatsApp integration

**Returns:** Conversation counts, conversion counts, revenue attributed to WhatsApp touchpoints.

---

### top_dm_ads

Messaging-ad (DM Ads) leaderboard for a profile, ranked by cost per high-intent conversation. Covers Click-to-Message channels: `ig_ctm`, `wa_ctwa`, `lead_dm`.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `profile_id` | UUID | Yes | — |
| `date_from` | `YYYY-MM-DD` | No | 28 days ago |
| `date_to` | `YYYY-MM-DD` | No | today |
| `sort_by` | string | No | `cost_per_high_intent` (`high_intent_rate`, `composite_score`, `spend`, `high_intent_conversations`) |
| `limit` | number | No | `10` (max 50) |

**Cost:** 1 unit

**Returns:** Array of ads with `high_intent_conversations` (pricing / consultation / demo intents), `high_intent_rate` + `high_intent_band` (Wilson sample confidence: `low` / `medium` / `high`), `cost_per_high_intent` (the hero metric), `cost_per_conversation`, reply-depth rates, attributed revenue/ROAS, booked appointments, closed deals, `data_quality_tier`, intent breakdown, and any open recommendations targeting the ad. PII in sample conversations is server-side redacted.

---

## Workspace creative intelligence

The tools in this section (and the recommendation section below) are **workspace-scoped**: pass `workspace_id` (inferred when you have exactly one) and they read across every profile you can access in the workspace. They are backed by the creative feature store, which is re-scored daily — `meta.data_as_of` reflects the last workspace re-score, not connector sync time.

### top_workspace_performers

The best-performing ads across ALL client profiles in a workspace, scored against comparable creative (cohort-normalized, smoothed, maturity-staged).

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `score_window` | `7d` \| `14d` \| `28d` \| `lifetime` | No | `28d` |
| `limit` | number | No | `10` (max 50) |
| `profile_ids` | UUID[] | No | — |
| `objectives` | string[] | No | — |
| `truth_grades` | string[] | No | — (`predicted`, `attributed`, `lift`) |
| `outcome_kinds` | string[] | No | — (`cash`, `pipeline`, `messaging`, `meta`, `none`) |
| `formats` | string[] | No | — |
| `min_spend` | number | No | — |
| `has_video` | boolean | No | — |
| `fatigue_risk_tiers` | string[] | No | — (`low`, `medium`, `high`, `critical`) |
| `fatigue_states` | string[] | No | — (`active`, `paused`, `degraded`) |

**Cost:** 1 unit

**Scope:** workspace

**Returns:** Each ad carries **three distinct measures — never merge them**:

1. `composite_score` (0–100) — a transparent rule-based blend of cohort percentiles
2. `top_performer_likelihood` — a learned field `{ value, status, reason }`: the ML ranker's 0–1 probability, `served` only past the serving gate; while `withheld` say «not evaluated», never a number. Never ROAS.
3. `attributed_revenue` / `roas` — real cash attribution (present when `truth_grade='attributed'`)

Plus `primary_outcome_kind` (which metric to headline per ad: `cash` → revenue/ROAS, `pipeline` → attributed pipeline outcomes, `messaging` → conversations started, `meta` → Meta's own reported conversions — always labeled as Meta's attribution, `none` → composite score only), `maturity_stage` (`cold` → `early` → `mature` → `calibrated`), `reason_codes[]` explaining why the ad ranks, and a `fatigue` block (NULL until the prediction pass has scored the ad). There is no forecast block: Atribu does not predict an ad's next week, and the fields that used to claim it were NULL on every row (#1493).

---

### top_workspace_creative_patterns_v2

Workspace-level creative pattern miner: which structural patterns (archetype cluster, narrative arc, dominant-third role combo, role presence, social-proof duration bucket, hook×claim×CTA) win MORE than the workspace baseline.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `score_window` | `7d` \| `14d` \| `28d` \| `lifetime` | No | `28d` |
| `min_cluster_size` | number | No | `3` (min 3, max 50) |
| `profile_ids` | UUID[] | No | — |
| `objectives` | string[] | No | — |
| `truth_grades` | string[] | No | — |
| `outcome_kinds` | string[] | No | — |
| `formats` | string[] | No | — |
| `min_spend` | number | No | — |
| `has_video` | boolean | No | — |

**Cost:** 1 unit

**Scope:** workspace

**Returns:** One row per (pattern_dim, pattern_value) with `sample_n`, `winner_n`, `win_rate`, `workspace_baseline_win_rate`, `lift_vs_workspace`, Wilson 95% confidence-interval bounds, and `exemplar_ad_external_ids` (top 5 ads embodying the pattern). A pattern whose confidence interval straddles the baseline is sampling noise, not signal. This is a pattern-level win-rate signal, NOT cash ROAS — pair with `top_workspace_performers` for per-ad cash impact.

---

### top_workspace_archetype_summary

The workspace's creative archetype clusters — named via LLM from exemplars — ranked by lift over the workspace baseline. Use as the "replicate these patterns" headline; use `top_workspace_creative_patterns_v2` for finer-grain dimensions.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `score_window` | `7d` \| `14d` \| `28d` \| `lifetime` | No | `28d` |

**Cost:** 1 unit

**Scope:** workspace

**Returns:** One row per archetype cluster with `label`, `description`, `winning_signal`, `n_ads`, `n_winners`, `win_rate`, `workspace_baseline_win_rate`, `lift_vs_workspace`, Wilson 95% CI bounds, and top-5 exemplar ads.

---

### top_workspace_experiments

Meta Conversion-Lift / Split-Test experiments tracked for the workspace.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `experiment_id` | UUID | No | — (pass to get the full detail document for one experiment) |

**Cost:** 1 unit

**Scope:** workspace

**Returns (list mode):** One row per experiment with `type` (`LIFT`, `CONTINUOUS_LIFT_CONFIG`, `GEO_LIFT`, `SPLIT_TEST`, `SPLIT_TEST_V2`), `derived_state` (`scheduled` / `running` / `observing` / `results_pending` / `complete` / `canceled` — derived from Meta timestamps), `best_grade` (A/B/C), and the primary lift point estimate + CI bounds. **Detail mode** (with `experiment_id`) adds cells, objectives, latest results, and the top-10 modeled creative attributions.

<Callout type="info" title="Lift is cell-level; split tests are Atribu-computed">
Meta returns lift only at the cell level — per-creative lift is always modeled. `SPLIT_TEST` results are computed by Atribu from its own fact tables (Meta exposes no results endpoint for split tests).
</Callout>

---

### top_workspace_fatigue_risk

The Cox wear-out **prediction**, governed by the serving gate (#1761): while no Cox model has shown out-of-time skill above chance, the tool returns `wear_out_model.status = "withheld"` and an empty `ads` list — «not evaluated», never «nothing at risk». When served: which ads are most likely to pause in the next 30 days, ranked by `hazard_30d_pause × spend`; only active ads in `high` / `critical` tiers appear.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `score_window` | `7d` \| `14d` \| `28d` \| `lifetime` | No | `28d` |
| `top_n` | number | No | `20` (max 50) |
| `min_spend` | number | No | `100` (pass `0` to include all) |

**Cost:** 1 unit

**Scope:** workspace

**Returns:** Ranked ads whose learned values — `hazard_30d_pause`, `hazard_30d_degradation`, `expected_lifespan_days`, `risk_tier`, `risk_rank_score` — are each `{ value, status, reason }` (read `value` only when `status = "served"`), plus workspace totals `total_budget_at_risk` (spend across the listed ads) and `total_expected_pause_loss_30d` (sum of hazard × spend — a distinct quantity, don't conflate the two).

---

### workspace_pattern_gaps

Cross-profile pattern-coverage analysis: for each winning workspace pattern, which profiles have NO ad expressing it. Answers "what test should I run next?" with concrete cross-profile replication opportunities.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `score_window` | `7d` \| `14d` \| `28d` \| `lifetime` | No | `28d` |
| `limit` | number | No | `10` (max 50) |
| `min_lift` | number | No | `0.05` (minimum `lift_vs_workspace` to qualify; 0–2) |

**Cost:** 1 unit

**Scope:** workspace

**Returns:** Rows ordered by `lift_vs_workspace` DESC, each with `pattern_dim` / `pattern_value`, the lift + CI lower bound, `source_profile_ids` (where the pattern lives), `gap_profile_ids` (profiles missing it), and exemplar ads.

---

### workspace_historical_lift_band

Historical lift distribution from graduated Conversion-Lift / Split-Test experiments in the workspace. Anchors expectations before committing to a new lift study.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `profile_id` | UUID | No | — (narrow to one client's history) |
| `lookback_days` | number | No | `365` (min 30, max 1095) |

**Cost:** 1 unit

**Scope:** workspace

**Returns:** min / p25 / p50 / p75 / max of `relative_uplift` (% lift over the comparison cell) across experiments with `derived_state` `complete` or `results_pending`, plus the 5 most recent exemplars. Returns `n_experiments: 0` when there's no graduated history.

---

### experiment_power_calc

Statistical power calculator for a Conversion-Lift study (one cell + holdout). Pure math — reads no data.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `baseline_rate` | number | Yes | — (0 < p < 1, e.g. `0.024` for 2.4%) |
| `daily_spend` | number | Yes | — |
| `cost_per_sample` | number | Yes | — |
| `holdout_pct` | number | No | `0.2` (0.05–0.5) |
| `days_to_run` | number | No | `14` (1–180) |

**Cost:** 1 unit

**Scope:** workspace

**Returns:** The minimum detectable effect (MDE) at 80% power / two-sided α = 0.05, days-to-read, 0.5× and 2× spend comparison scenarios, and a recommendation (keep spend / double spend / cohort too sparse for this design).

---

## Recommendation tools

### list_workspace_recommendations

The AI media buyer's recommendations across all profiles in a workspace, ranked by expected impact.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `profile_ids` | UUID[] | No | — |
| `statuses` | string[] | No | `["open"]` (`open`, `applied`, `dismissed`, `superseded`, `expired`, `rolled_back`) |
| `kinds` | string[] | No | — (`scale_winner`, `pause_underperformer`, `budget_reallocate_winners`, `creative_refresh_pre_fatigue`) |
| `risk_tiers` | string[] | No | — (`safe`, `medium`, `manual_only`). Accepted but **not applied** by the read today; filter on the returned `risk_tier` instead. |
| `cohort_objective` | string | No | — (`messaging`, `sales`, `leads`, `traffic`, `awareness`) |
| `score_window` | `7d` \| `14d` \| `28d` \| `lifetime` | No | `28d` |
| `limit` | number | No | `20` (max 100) |

**Cost:** 1 unit

**Scope:** workspace

**Returns:** Recommendations with `kind`, `risk_tier` (`safe` = auto-applicable if the workspace opted in, `medium` = always requires explicit confirm, `manual_only` = no Meta call), `ad_name` (render this, never `target.ad_id`), structured `rationale_jsonb` — the cash kinds carry `window_days`, `attributed_cash_outcomes` (cash SALES, not leads), `cost_per_cash_outcome`, `profile_median_cost_per_cash_outcome`, `cost_delta_pct`, `spend`, `currency` — plus an optional `rationale_text` commentary line, `suggested_modifications` (action + params; quote `params.budget_change_pct`, never a literal), `expected_impact_dollars` (arithmetic on the ad's own observed unit economics, phrased with "if" — not a forecast), `confidence` (0–1), and lifecycle timestamps. A recommendation ATTESTS: it exists because the ad already closed at least 3 attributed sales below its account's median cost per sale.

---

### diagnose_recommendation

Full diagnostic for a single recommendation — "what happened with this recommendation?" Use before `apply_recommendation` to understand prior attempts.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `recommendation_id` | UUID | Yes | — |

**Cost:** 1 unit

**Scope:** workspace

**Returns:** The recommendation row (kind / risk tier / status / rationale / suggested modifications / target / impact / confidence / lifecycle), the most recent application if any (with pre-change and post-change Meta state, the Meta API result, verification and rollback timestamps), and the related write-back audit chain, most recent first.

---

### apply_recommendation

Apply a recommendation against Meta. This is a **write tool** with the same three-step safety flow and gates as `send_meta_conversions` — see the [Write-Back guide](/docs/mcp/write-back).

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `recommendation_id` | UUID | Yes | — |
| `mode` | `preview` \| `dry_run` \| `confirm` | Yes | — |
| `idempotency_key` | string | No | Auto-derived per (user, recommendation, day) when omitted |
| `applied_by` | UUID | No | — |

**Cost:** 10 units

**Scope:** workspace

**Requires (for `dry_run` and `confirm`):** `mcp:write` scope, workspace write-back enabled, workspace owner/admin role. `preview` needs none of these. `confirm` additionally requires the recommendation to be `open` and unexpired.

`preview` shows what the worker would do (target ad/ad-set, planned Meta calls, budget % delta) with no side effects. `dry_run` records an audit row capturing intent. `confirm` enqueues the job for the pipeline worker, which captures pre-change Meta state, executes the write, and verifies the change landed ~5 minutes later. Replaying `confirm` with the same idempotency key returns the existing application — no double-write.

---

## Creative generation

### generate_creative_brief

Generate an OPINIONATED creative brief for one profile's next ad batch — grounded in that profile's own Top Performers + winning creative patterns, and (when the `marketingskills` reference frameworks are available on the server) the ad-creative/copywriting/paid-ads sections. Applies the 70/20/10 rule: 70% of the returned briefs are **proven** (a concrete angle lifted from an ad already winning for this profile, restated as fresh creative), 20% are **adjacent** (a deliberate variation on a proven winner), 10% are **bet** (a genuinely new angle not tied to an existing winner).

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `objective` | `cash` \| `pipeline` \| `messaging` \| `meta` | No | The profile's own headline outcome kind — its top-ranked ad's `primary_outcome_kind` (falls back to `cash` if there are no ads yet) |
| `count` | number | No | `3` (1–5) |
| `format` | `video` \| `image` \| `carousel` \| `any` | No | `any` |
| `constraints` | string | No | — (free text, max 500 chars — brand voice, compliance, exclusions; respected verbatim) |

**Cost:** 5 units, plus one LLM generation call charged against the workspace's shared monthly AI budget — the same budget the in-app AI copilot and the daily morning brief draw from.

**Scope:** profile

**Returns:** `briefs[]`, each carrying `bucket` (`proven` \| `adjacent` \| `bet`), `hook`, `angle`, `format`, `primary_text`, `headline`, `cta`, `grounded_in` (the ad(s) it derives from, by `ad_external_id`/`ad_name`), and `why` — a one-sentence citation **computed from the cited ad's own numbers** (`composite_score`, `top_performer_likelihood`, headline outcome), never the model's own claim. `bucket_counts` shows the actual 70/20/10 split used (largest-remainder apportionment: count=3 → 2 proven/1 adjacent/0 bet, count=5 → 3/1/1 — ties break toward the smaller bucket so the 10% "bet" allocation is never rounded away). `frameworks_used[]` lists which `marketingskills` sections grounded the call; when the submodule isn't available on the server, `frameworks_used` is empty and `degraded_reason` explains why — the brief still generates, grounded in Top Performers data alone.

No images, no video, no Meta writes — copy only. Errors `data_unavailable` if the AI provider isn't configured, or `rate_limited` if the workspace's monthly AI budget is exhausted (resets next month, or a workspace admin can raise the cap).

**Example call:**

```json
{
  "tool": "generate_creative_brief",
  "arguments": {
    "profile_id": "0f2a1c3e-...",
    "count": 3,
    "format": "video",
    "constraints": "No before/after imagery; Spanish only."
  }
}
```

---

## Onboarding tools

The golden path from an empty account to attributed revenue, one tool per step, in the order you call them. Every tool here is a **thin wrapper on one `/api/v1` route**: it forwards *your own* MCP token, and tenancy, scopes and the audit trail are decided once, by the API. There is no second implementation of any of them.

**Three things to know before you use any of them.**

1. **Every write is `preview` then `confirm`.** `preview` describes the exact call it would make — route, body, and where the API can answer it, a real dry-run count — and has no side effect and no gates. `confirm` performs it. Always preview, show the user, then confirm. `mode` is required: there is no safe default.
2. **Writes need three things, not one.** The token's `mcp:write` scope, the workspace's **MCP write-back** setting (Settings → Privacy & MCP, off by default), and an owner/admin role. A missing scope answers `insufficient_scope`; a missing role answers `insufficient_role` — a different code because re-consenting cannot raise a role. Reads and previews keep working regardless.
3. **The hand-off pattern is how a human does their part.** Some steps only a person can take: granting an OAuth consent, signing a DPA, paying. For those you mint a hand-off, **give the user the `url` verbatim**, and poll `get_handoff({ id })` until `status` leaves `pending`. You never open the URL yourself.

<Callout type="info" title="Start with whoami">
`whoami` now returns a `readiness` summary — how many golden-path steps are done, which is next, and every step that is not done with the reason. It costs 0 units. When a read comes back empty, the answer is almost always there: a missing connection, not a missing result.
</Callout>

### create_workspace

Create a new Atribu workspace owned by you. The first call when `whoami` shows no workspaces.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mode` | `preview` \| `confirm` | Yes | — |
| `name` | string | Yes | 1–80 characters |
| `timezone` | string | No | IANA zone; defaults to UTC |
| `idempotency_key` | string | No | Derived from the payload when omitted |

**Cost:** 5 units · **Wraps:** `POST /api/v1/workspaces` · **Scope:** `mcp:write`

<Callout type="info" title="The only write with a relaxed gate">
This is the one write that does **not** require workspace write-back or an admin role: both live on a workspace, and this call creates one. You become its owner. Since your token needs `mcp:write` just to call this, the new workspace starts with write-back already **on** — call `create_profile` right after with no settings step in between.

A replay of the same name by the same caller within a short window returns the SAME workspace with `created: false` rather than minting a second one; a different name, or the same name once the window has passed, always creates.
</Callout>

### create_profile

Create a Profile — one advertiser, brand or client inside a workspace, and the unit every other tool is scoped to.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mode` | `preview` \| `confirm` | Yes | — |
| `workspace_id` | UUID | No | Inferred if you have one workspace |
| `name` | string | Yes | — |
| `timezone` | string | No | Defaults to the workspace's |
| `currency` | string | No | ISO-4217; defaults to USD |
| `site_domain` | string | No | Where the tracker will run |

**Cost:** 5 units · **Wraps:** `POST /api/v1/profiles` · **Requires:** `mcp:write`, write-back enabled, owner/admin

The plan's `active_profiles` limit is enforced by the API, so a refusal here may be a **billing** answer rather than a permission one. Read the message.

### create_demo_profile

Seed a fully-populated demo profile: ad spend, touches, leads, appointments, payments and a recording CAPI destination — so every read tool has real shapes to return before the customer has connected anything.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mode` | `preview` \| `confirm` | Yes | — |
| `workspace_id` | UUID | No | Inferred if you have one workspace |
| `timezone` | string | No | — |
| `currency` | string | No | ISO-4217; defaults to USD |

**Cost:** 5 units · **Wraps:** `POST /api/v1/profiles/demo` · **Requires:** `mcp:write`, write-back enabled, owner/admin

Exactly one demo per workspace: a second `confirm` re-seeds it and answers `created: false`. Its exports are **recorded, never sent to Meta**.

### delete_demo_profile

Delete the workspace's demo profile and everything seeded under it. Irreversible, and marked `destructiveHint`. Only a profile flagged `is_demo` is reachable from here — a real profile never is.

| Parameter | Type | Required |
|-----------|------|----------|
| `mode` | `preview` \| `confirm` | Yes |
| `workspace_id` | UUID | No |

**Cost:** 1 unit · **Wraps:** `DELETE /api/v1/profiles/demo` · **Requires:** `mcp:write`, write-back enabled, owner/admin

### issue_tracking_key

Mint a `trk_live_` tracking key — what the browser tracker authenticates with.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mode` | `preview` \| `confirm` | Yes | — |
| `name` | string | No | Label, e.g. `Main site` |
| `idempotency_key` | string | No | A repeated `confirm` with the same key returns the FIRST key rather than minting a second |

**Cost:** 1 unit · **Wraps:** `POST /api/v1/tracking/keys` · **Requires:** `mcp:write` (→ `tracking:write`), write-back enabled, owner/admin

The public key is **not a secret**: the console's own installers already embed the identical value in a `<script>` tag that ships to every visitor.

### get_tracker_installer

The ready-to-paste install for one of three surfaces. Read-only — it renders an installer, a human still pastes it.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `surface` | `snippet` \| `gtm` \| `shopify_pixel` | No | Default `snippet`. Ask the customer how their site is built |
| `key_id` | string | No | The profile's active key when omitted |
| `domain_id` | string | No | — |
| `include_meta_pixel` | boolean | No | `snippet` only |

**Cost:** 1 unit · **Wraps:** `GET /api/v1/tracking/snippet`, `GET /api/v1/tracking/installers/gtm`, `GET /api/v1/tracking/installers/shopify-pixel`

Give the output to the user **verbatim**. Re-typing a script tag introduces a typo they then have to debug.

### start_connect

Start an OAuth connection and get a URL to hand to your user. **You cannot complete this** — a consent needs a browser and a person.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mode` | `preview` \| `confirm` | Yes | — |
| `provider` | `meta_ads` \| `google_ads` \| `google_search_console` \| `gohighlevel` \| `stripe` \| `mercadopago` | Yes | Ask which platform they actually advertise on. `shopify` is **not** offered by this tool — it has no `shop` input; a partner app connects a store with the API route and `shop` |
| `return_to` | URL | No | Where to send them afterwards |

**Cost:** 1 unit · **Wraps:** `POST /api/v1/connections/{provider}/handoff` · **Requires:** `mcp:write`, write-back enabled, owner/admin

The flow: `confirm` → give the user `url` exactly as it is → poll `get_handoff({ id })` → read `result`. The link is session-less (they do not need an Atribu account, and it works on a phone), single-use, and expires in ~45 minutes — mint it when the person is ready rather than in advance; minting twice is two hand-offs, not an error.

On `completed`, `result.connection_id` names the connection that was written — unless `result.pending_selection` is set, which means the consent exposed several accounts and a human still owes a choice (`GET /api/v1/connections/pending/{provider}` then `POST …/finalize`). On `cancelled`, the person cancelled on the provider's screen (`result.reason: user_cancelled`). On `failed`, `result.reason` says why, for example `provider_denied`, `no_candidates` (**not** retryable — they must be granted access at the provider first) or `token_exchange_failed`; the full list is in [Connections](/docs/api/connections#when-it-fails-or-is-cancelled).

<Callout type="info" title="Shopify is not offered by this tool">
The route accepts `shopify` too (#2104), but only with the store in `shop`, which this tool does not ask for: a merchant installing Atribu for themselves does it from the Shopify App Store. A partner app connecting a tenant's store calls `POST /api/v1/connections/shopify/handoff` with `shop`. The tool rejects `shopify` at parse time rather than minting a hand-off with no store.
</Callout>

### get_handoff

Poll a hand-off you gave a human. Works for **every** kind — `connect`, `pick`, `sign_dpa`, `checkout`, `approve` — because they all settle the same way.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | UUID | Yes | The `id` the minting tool returned — not the handle the URL carries |

**Cost:** 0 units · **Wraps:** `GET /api/v1/handoffs/{id}`

**Returns:** `{ id, kind, status, url, expires_at, created_at, completed_at, result, still_pending, note }`. `status` is `pending` until they finish, then `completed` / `expired` / `cancelled` / `failed`.

An **expired** hand-off answers `status: "expired"`, never a 404 — so a 404 here means the id is wrong, not that the link lapsed. Poll no faster than every few seconds; do not mint a second hand-off while one is pending.

### list_outcome_events

Which raw `outcome_events` event types this profile has actually received in a window, and how many of each.

| Parameter | Type | Required |
|-----------|------|----------|
| `date_from` | `YYYY-MM-DD` | Yes |
| `date_to` | `YYYY-MM-DD` | Yes |

**Cost:** 2 units · **Wraps:** `GET /api/v1/goals/outcome-counts`

The first call before proposing any conversion definition: it tells you what the customer's CRM and tracker really emit, rather than what they are called in a brochure. An empty result means nothing is arriving — check `get_readiness` before assuming the window is wrong.

### suggest_conversion_definitions

Atribu's own proposal for this profile's conversion definitions, derived from the events it has received — including a suggested `revenue_type`, whether the conversion should be attribution-eligible, and the Meta event it maps to.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `days` | number | No | Lookback; `outcome_events` is durable, so a year is allowed |
| `min_events` | number | No | Ignore event types below this count |
| `limit` | number | No | Maximum suggestions |

**Cost:** 2 units · **Wraps:** `GET /api/v1/goals/definitions/suggestions`

Propose from this, show the user, create what they confirm. Do **not** invent a definition when this returns nothing.

### create_conversion_definition

Create a `conversion_definitions` row — what this profile counts as a conversion, what kind of revenue it carries, and how far back a touch may be credited.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mode` | `preview` \| `confirm` | Yes | — |
| `conversion_key` | string | Yes | Equals `conversions.conversion_type`; taken verbatim |
| `display_name` | string | Yes | — |
| `source_event_names` | string[] | Yes | From `list_outcome_events`; must not overlap another definition |
| `revenue_type` | `cash` \| `pipeline` \| `gross` | Yes | See the callout |
| `attribution_eligible` | boolean | Yes | — |
| `lookback_window_days` | number | Yes | 1–365 |
| `is_default` | boolean | No | Make it the headline conversion |

**Cost:** 2 units · **Wraps:** `POST /api/v1/goals/definitions/preview`, `POST /api/v1/goals/definitions` · **Requires:** `mcp:write` (→ `goals:write`), write-back enabled, owner/admin

`preview` runs the API's **own** dry run and returns how many events and how much revenue the definition would match, so you can show a real number before writing.

<Callout type="warn" title="Only cash counts for ROAS">
If the customer said "sales" or "revenue", they mean `cash`. `pipeline` is forecast value and `gross` is pre-refund; a definition created as `pipeline` fills the funnel and contributes nothing to any ratio — a dashboard of zeroes, with no error anywhere.
</Callout>

### set_attribution_windows

Read or change this profile's attribution windows. A partial update: an omitted field is left untouched.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mode` | `preview` \| `confirm` | Yes | `preview` is also how you simply read them |
| `click_window_days` | number | No | 1–365. Default 30 |
| `view_window_hours` | number | No | 1–168. Default 24 |
| `first_touch_window_days` | number | No | 1–365. Default 90 |
| `cash_window_days` | number \| null | No | Cash-only override; `null` clears it |
| `confirm_token` | string | No | The `confirm_token` the `preview` returned. A confirm whose windows changed since is refused — preview again |

**Cost:** 2 units · **Wraps:** `GET`/`PATCH /api/v1/profile/attribution-settings` · **Requires:** `mcp:write`, write-back enabled, owner/admin

<Callout type="warn" title="This rewrites history">
A window decides which touches attach to which conversion, so a save queues a full-profile replay and **already-reported numbers change**. The result carries `replay_queued`; when it is `false`, the numbers still reflect the old windows until a replay runs, and the tool says so.
</Callout>

A `confirm` is bound to the windows it finds, not to the payload and the day: asking for the windows the profile already has answers `outcome: "no_change"` and queues no replay (which is also how a double-click deduplicates), and setting A, then B, then A again on the same day really writes A the third time.

### sign_dpa

Get a signing link for the Data Processing Agreement — or the combined DPA & HIPAA BAA — and hand it to your user. Conversion Sync will not export anything until the DPA is accepted, so this usually comes before `configure_meta_capi`.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mode` | `preview` \| `confirm` | Yes | — |
| `document` | `dpa` \| `baa` | No | Default `dpa`. `baa` is the combined DPA & HIPAA BAA click-wrap that Healthcare Mode requires — ask before choosing it, it is a bigger commitment rather than a superset anyone wants by default |

**Cost:** 1 unit · **Wraps:** `POST /api/v1/legal/dpa/handoff`, `POST /api/v1/legal/baa/handoff` · **Requires:** `mcp:write` (→ `exports:write`), write-back enabled, owner/admin

**Already signed is a success, not a failure.** A document that has already been accepted comes back `already_signed: true`, `status: "completed"` and **no** `url`. Say so and move on — re-running a checklist must never ask a customer to sign twice, and no second signature is collected.

<Callout type="warn" title="You cannot sign it, and that is deliberate">
There is a second route, `POST /api/v1/legal/{document}/accept`, that records an acceptance directly. **This tool does not wrap it, and the API refuses an MCP token there with a 403 on purpose:** your token names a real person, but it is the credential they minted *for an agent* — so accepting with it is an agent signing on their behalf. A signature an agent could produce is worth nothing, which is the entire reason the hand-off exists. Give the human the `url` and poll `get_handoff({ id })`.
</Callout>

<Callout type="info" title="Two documents, two routes">
`dpa` and `baa` are separate routes, not a body field — the document is the path segment, and the signer's identity comes from whoever opens the URL rather than from the caller. Pick `baa` only when the customer runs Healthcare Mode.
</Callout>

### configure_meta_capi

Configure Meta Conversions API: the dataset conversions are sent to, plus one signal rule per conversion you want Meta to receive. This is what `send_meta_conversions` means by "configure Conversion Sync first".

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mode` | `preview` \| `confirm` | Yes | — |
| `dataset_id` | string | Yes | The Meta dataset / pixel id from Events Manager. **Not** the ad account id |
| `connection_id` | UUID | No | Resolved automatically when the profile has exactly one connected Meta account |
| `mappings` | array | Yes | `{ conversion_definition_id, meta_event_name, value_mode?, name? }`, at least one |
| `confirm_token` | string | No | The `confirm_token` the `preview` returned. A confirm whose Conversion Sync state changed since is refused — preview again |

**Cost:** 2 units · **Wraps:** `GET /api/v1/connections`, `GET`/`POST /api/v1/exports/destinations`, `GET`/`POST /api/v1/exports/rules` · **Requires:** `mcp:write` (→ `exports:write`), write-back enabled, owner/admin, a connected Meta Ads account

Show the `preview` to the user before confirming: a wrong dataset id sends their conversions to somebody else's pixel. When the profile has **several** connected Meta accounts the tool refuses and lists them rather than guessing.

`confirm` writes the destination and each rule separately and reports each outcome. A partial result is reported as `complete: false` with `failed_rules` — never as success, and never rolled back.

It writes only what is missing: an enabled destination for the same dataset and connection is reused, a mapping that already has a matching enabled rule is not created twice, and a configuration already fully in place answers `outcome: "no_change"`. So a retry after a partial failure completes the configuration instead of failing on the destination, and re-applying a dataset you switched away from earlier the same day really writes it.

### send_test_event

Send one synthetic conversion through the profile's Meta CAPI configuration so the customer can watch it arrive in Events Manager → Test Events.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mode` | `preview` \| `confirm` | Yes | — |
| `test_event_code` | string | Yes | From Events Manager → Test Events |
| `destination_id` | UUID | No | Oldest enabled destination when omitted |
| `rule_id` | UUID | No | Borrow this rule's Meta event name and privacy override |
| `channel` | `website` \| `business_messaging` | No | — |

**Cost:** 10 units · **Wraps:** `POST /api/v1/exports/test` · **Requires:** `mcp:write` (→ `exports:write`), write-back enabled, owner/admin

The only tool in this family that talks to a third party, which is why it is marked `destructiveHint`: it puts an event on the customer's real dataset. **Always pass `test_event_code`** — without it the event is indistinguishable from production traffic. It is deliberately not deduplicated: a second send is a legitimate second test.

---

## Write-back tools

### send_meta_conversions

Queue attributed conversions for delivery to Meta Conversions API (CAPI) through Atribu's export ledger. This tool has a three-step safety flow: preview, dry-run, confirm.

**The tool does not call Meta.** `confirm` writes `conversion_exports` ledger rows and enqueues one export job; the pipeline worker performs the send under the booking cluster's canonical `event_id`, which is what lets Meta's 48-hour dedup collapse it against the Pixel and against any other ingestion path. Poll `GET /api/v1/exports/{batch_id}` or `GET /api/v1/exports/ledger` for the delivery outcome.

See the dedicated [Write-Back guide](/docs/mcp/write-back) for the full flow, safety rails, and examples. `apply_recommendation` (above) is the second write tool — same gates, same audit trail.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `mode` | `preview` \| `dry_run` \| `confirm` | Yes | — |
| `window_start` | date | Yes | — |
| `window_end` | date | Yes | — |
| `event_types` | string[] | Yes | — |
| `destination` | `meta_capi` | No | `meta_capi` (the only value; Google destinations run through `POST /api/v1/exports`) |
| `pixel_id` | string | No | — (validated against the configured destinations; never used to route) |
| `idempotency_key` | string | For confirm | — |
| `test_event_code` | string | No | — (inert; the tool no longer makes a Meta test-event call) |
| `max_events` | number | No | `500` (max 500) |

**Cost:** 10 units

**Requires:** `mcp:write` scope, a connected Meta Ads account, an enabled Conversion Sync `meta_capi` export destination, workspace write-back enabled, workspace admin role.

---

### start_plan_upgrade

Start a paid plan upgrade. You cannot enter a card, so this mints a **hand-off**: a URL to give the human, plus a `handoff_id` to poll. The human pays in any browser; Stripe's webhook settles the hand-off, and `GET /api/v1/workspaces/{id}/subscription` then shows the new tier.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `plan` | `growth` \| `agency` | Yes | — |
| `interval` | `monthly` \| `annual` | No | `monthly` |

**Cost:** 10 units

**Scope:** workspace

**Requires:** `mcp:write` scope, workspace write-back enabled, workspace owner/admin role. An `analyst` is refused: reading numbers is not committing the workspace to a recurring charge.

**Returns:** `handoff_id`, `status`, `url` (present only while `status` is `pending`), `expires_at`, `no_change`, and a `poll` block naming `GET /api/v1/handoffs/{id}`.

A workspace **already on that plan** comes back `status: "completed"` with `no_change: true` and no URL — never a second subscription. A workspace mid-trial on that plan counts as being on it: buying again would create a second subscription rather than convert the trial.

Downgrades are not a checkout — they go through the Stripe Customer Portal, because opening a second subscription for a lower tier would double-bill. Read `GET /api/v1/workspaces/{id}/subscription` first: its `limits` tell the human *why* they need the upgrade, and its `upgrade_available` lists exactly what this workspace can be sold.

**Example call:**

```json
{
  "tool": "start_plan_upgrade",
  "arguments": {
    "workspace_id": "0f2a1c3e-...",
    "plan": "growth"
  }
}
```

---

### pause_campaign

Pause one Meta campaign with **one** campaign-level write that stops every ad under it — never a per-ad fan-out. A thin wrapper on `POST /api/v1/campaigns/{id}/pause`: the write, its audit row and its undo are the API's.

`preview` calls `GET /api/v1/campaigns/{id}/status` and writes nothing: the campaign's live status, how many delivering ads the pause would stop, its daily budget and recent daily spend, and `pause.available` with a `reason` when it is not. Show that to the person, then call again with `confirm` and the preview's `confirm_token`: the replay guard is derived from it, so a later toggle (pause → resume → pause the same day) is always a new write, while a retry of the same decision deduplicates (the campaign is already in the asked-for state, so it answers `no_change`). A token from a state that has changed since the preview is refused as `invalid_input` — preview again. The token is the API's own `confirm_token` from `GET /api/v1/campaigns/{id}/status`. The confirmed answer carries `previous_status`, `affected_ads_count`, the ledger `action_id` and `undo` (the resume call). Pausing a paused campaign is `outcome: "no_change"`.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `mode` | `preview` \| `confirm` | Yes | — |
| `campaign_id` | string (Meta campaign id) | Yes | — |
| `confirm_token` | string (from `preview`) | No | read by the tool itself |
| `applied_by` | uuid | No | your own token's user |
| `idempotency_key` | string | No | derived from the call |

**Cost:** 2 units

**Requires:** `mcp:write` scope, a connected Meta Ads account with `ads_management`, workspace write-back enabled, workspace owner/admin role.

---

### resume_campaign

Resume one paused Meta campaign with one write. When Atribu paused it, this is that pause's undo (`rollback_of` names the pause). **Ads paused on their own stay paused.** `preview` reads the live status and how many ads the resume would let deliver again. Resuming an active campaign is `outcome: "no_change"`. A merchant connection without `ads_management` is `connector_required` (`api_code: "meta_write_permission_missing"`), not your token's scope.

Same parameters, cost and requirements as `pause_campaign`.

---

## Spec-driven tools

Two generic tools cover every read the API publishes that has no dedicated tool, so the tool list stays short. The catalog is generated from `openapi.json`; a path that is not in it (a write, an admin route) is refused before any request.

### list_api_operations

Search the published reads. Pass `query` to find candidates, or `path` to get one operation's full parameter list. Read-only.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `query` | string | No | — |
| `path` | string (exact operation path) | No | — |

**Cost:** 1 unit

---

### call_api_operation

Call one published read as you, the signed-in person. The API applies your access, scopes and privacy settings and the answer is returned unchanged. Reads only: writes are their own tools, with a preview and a confirmation.

| Parameter | Type | Required | Default |
|-----------|------|----------|---------|
| `path` | string (as returned by `list_api_operations`) | Yes | — |
| `params` | object (path and query parameters by name) | No | — |
| `profile_id` | uuid | No | — (required for profile-grain operations) |

**Cost:** 1 unit

---

## Response envelope

Every tool returns a consistent envelope:

```json title="Response structure"
{
  "data": { },
  "meta": {
    "request_id": "01968a3b-...",
    "workspace_id": "...",
    "profile_id": "...",
    "window_start": "2026-04-01",
    "window_end": "2026-04-14",
    "attribution_model": "first_touch",
    "currency": "USD",
    "data_as_of": "2026-04-14T18:30:00Z",
    "freshness_by_provider": {
      "meta": "2026-04-14T18:30:00Z",
      "ghl": "2026-04-14T17:00:00Z"
    },
    "data_freshness_warning": null,
    "record_count": 12,
    "pii_level_applied": "masked"
  }
}
```

| Meta field | Description |
|------------|-------------|
| `request_id` | Unique ID for debugging and support |
| `data_as_of` | Oldest sync timestamp across required providers |
| `freshness_by_provider` | Per-provider last sync time |
| `data_freshness_warning` | Human-readable warning if data is stale (>6 hours) |
| `pii_level_applied` | `masked` or `full` -- what PII level was actually used |

## Related

<Cards>
  <Card title="Write-Back (Meta CAPI)" href="/docs/mcp/write-back">
    The full preview/dry-run/confirm flow for send_meta_conversions
  </Card>
  <Card title="Error Reference" href="/docs/mcp/errors">
    Error codes and troubleshooting
  </Card>
  <Card title="Attribution Models" href="/docs/concepts/attribution-models">
    How each model distributes credit
  </Card>
</Cards>

---

# Write-Back (Meta CAPI)

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`](#apply_recommendation--the-second-write-tool), 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.

<Callout type="info" title="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.
</Callout>

<Callout type="warn" title="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.
</Callout>

## 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](#human-approval--when-the-agent-is-not-the-one-who-decides) 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
```

<Callout type="info" title="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.
</Callout>

---

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

<Callout type="info" title="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.
</Callout>

---

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

<Callout type="info" title="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.
</Callout>

`confirm` then writes **nothing** and returns:

```json
{
  "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_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`](/docs/mcp/tools#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`](/docs/mcp/tools#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](#human-approval--when-the-agent-is-not-the-one-who-decides).
- **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.

## Related

<Cards>
  <Card title="Meta Integration" href="/docs/integrations/meta">
    Connect your Meta ad accounts
  </Card>
  <Card title="Available Tools" href="/docs/mcp/tools">
    All 56 MCP tools
  </Card>
  <Card title="Error Reference" href="/docs/mcp/errors">
    Error codes including write-back specific errors
  </Card>
</Cards>

---

# Privacy & PII

The MCP server masks personally identifiable information (PII) by default. This page explains how masking works and how to configure access to unmasked data.

## Default behavior

When a tool returns customer data (e.g. `explain_customer_journey`), PII fields are masked:

| Field | Masked value | Unmasked value |
|-------|-------------|----------------|
| Email | `j***@e****.com` | `jane@example.com` |
| Phone | `*******5678` (last 4 digits only) | `+1 234 567 5678` |
| Name | First name only (`Jane`) | Full name (`Jane Smith`) |

This applies automatically -- no configuration needed.

---

## Requesting unmasked data

To see full PII, three conditions must be met simultaneously:

<Steps>
<Step>

### Token scope

Your MCP token must include the `mcp:read_pii` scope. Create or rotate a token with this scope from **Developer > MCP Tokens**.

</Step>
<Step>

### Explicit request

Pass `include_sensitive: true` in the tool call. The AI tool will do this when you explicitly ask for unmasked data (e.g. "show me the actual email addresses").

</Step>
<Step>

### Workspace setting

A workspace admin must set the workspace PII mode to `full_default` in **Settings > Privacy & MCP**. The default is `masked_default`.

</Step>
</Steps>

If any condition is not met, the response is masked and `meta.pii_level_applied` is set to `"masked"`. No error is returned -- the tool gracefully falls back to masking.

---

## Workspace admin controls

Workspace administrators can configure two MCP-related settings from **Settings > Privacy & MCP**:

| Setting | Default | Description |
|---------|---------|-------------|
| PII mode | `masked_default` | Controls whether tokens with `mcp:read_pii` can see full PII |
| Write-back | Disabled | Controls whether tokens with `mcp:write` can CHANGE the workspace at all — create profiles, install tracking, define conversions, configure Meta CAPI, send conversions. Off means read-only: reads and every write tool's `preview` still work |

These settings apply to all MCP tokens used to access the workspace, regardless of which user created the token.

**One exception to the "Disabled" default.** A workspace created by `create_workspace` (or `POST /api/v1/workspaces`) through an MCP user token whose grant carries `mcp:write` starts with write-back already **on** — the person already consented to writes on the MCP OAuth screen, and is that workspace's only member and owner, so there is no one else's setting to protect. A workspace created from the console, from a connected app, or by a token holding only `mcp:read`, still starts Disabled. Either way this switch stays reachable only from **Settings > Privacy & MCP** — an MCP user token can never flip it, on a workspace it just created or any other.

---

## What is logged

MCP tool invocations log **metadata only**:

- Tool name, duration, status code, units debited
- Workspace ID, profile ID, request ID
- Record count, PII level applied

Raw tool arguments, response data, and PII are **never logged**. The `request_id` in every response can be used for support inquiries without exposing sensitive data.

---

## Data flow

```mermaid
flowchart TD
    A["AI Tool calls explain_customer_journey"] --> B{"include_sensitive?"}
    B -->|No| C["Return masked PII"]
    B -->|Yes| D{"Token has mcp:read_pii?"}
    D -->|No| C
    D -->|Yes| E{"Workspace pii_mode = full_default?"}
    E -->|No| C
    E -->|Yes| F["Return full PII"]
```

## Related

<Cards>
  <Card title="Authentication" href="/docs/mcp/authentication">
    Token scopes and rate limits
  </Card>
  <Card title="Write-Back" href="/docs/mcp/write-back">
    Meta CAPI write-back safety flow
  </Card>
  <Card title="Team & Workspace" href="/docs/settings/team">
    Manage workspace roles and permissions
  </Card>
</Cards>

---

# Error Reference

When an MCP tool call fails, the response includes `isError: true` and a structured error object. Your AI tool will typically explain the error and suggest a fix.

## Error response format

```json title="Error response"
{
  "error": {
    "code": "rate_limited",
    "message": "Per-minute unit cap exceeded. Slow down.",
    "retryable": true,
    "retry_after": 12,
    "docs_url": "https://www.atribu.app/docs/mcp/errors#rate_limited",
    "request_id": "01968a3b-..."
  }
}
```

Every error carries `docs_url` -- a deep link back to this page, to the exact code you got. Some errors include additional fields:

- `docs_url` -- a deep link to the section on this page for this exact `code`
- `required_scopes` -- present on `insufficient_scope` when the missing token scope is known by name
- `action_url` -- a link to the Atribu dashboard page where you can fix the issue
- `workspaces` -- a list of available workspaces (for `workspace_required`)
- `profiles` -- a list of available profiles (for `profile_required`)
- `support_hint` -- a suggestion to contact support with the `request_id`

---

## Error codes

### Authentication failures

Authentication failures do **not** return a structured tool error. A missing, malformed, revoked, or expired token gets an HTTP **401** response with a JSON-RPC error (`code: -32001`, message `"Unauthorized: invalid or missing token"`) and a `WWW-Authenticate` header. There is no distinct expired-vs-revoked signal.

**Fix:** check that the request carries `Authorization: Bearer atb_user_...`. If the token was revoked (or rotated more than 48 hours ago), create a new one from **Developer > MCP Tokens**.

---

### Scope errors

| Code | Retryable | Cause | Fix |
|------|-----------|-------|-----|
| <a id="workspace_required" /> `workspace_required` | No | User has multiple workspaces and none was specified | Pass `workspace_id` -- the error includes a list of your workspaces |
| <a id="profile_required" /> `profile_required` | No | Workspace has multiple profiles and none was specified | Pass `profile_id` -- the error includes a list of profiles |
| <a id="insufficient_scope" /> `insufficient_scope` | No | Token lacks the required scope for this tool | Create a new token with the needed scope, or re-consent with a wider one |
| <a id="insufficient_role" /> `insufficient_role` | No | Your ROLE in the workspace is too low for this write (#1058) | Ask a workspace owner/admin to run it, or to raise your role in **Settings > Members**. Re-consenting with a wider scope cannot fix a role, and the approval hand-off does not cover the setup tools — see below |

<Callout type="info" title="Single-resource inference">
If you have exactly one workspace or one profile, it's selected automatically. These errors only occur when there are multiple options.
</Callout>

---

### Rate limit errors

| Code | Retryable | Cause | Fix |
|------|-----------|-------|-----|
| <a id="rate_limited" /> `rate_limited` | Yes | Per-minute or per-period unit cap exceeded | Wait `retry_after` seconds, then retry |

The `retry_after` field tells you how many seconds to wait. AI tools that support retry will handle this automatically.

---

### Connector errors

| Code | Retryable | Cause | Fix |
|------|-----------|-------|-----|
| <a id="connector_expired" /> `connector_expired` | No | A required integration's OAuth token has expired | Re-authorize at the `action_url` in the error response |
| <a id="connector_required" /> `connector_required` | No | A required integration is not connected | Connect it at the `action_url` in the error response |

These errors include an `action_url` that links directly to the integrations page in your Atribu dashboard.

---

### Write-back errors

| Code | Retryable | Cause | Fix |
|------|-----------|-------|-----|
| <a id="writeback_disabled" /> `writeback_disabled` | No | Workspace admin has not enabled MCP write-back — workspaces an `mcp:write` agent created start with it on, so this only fires for a console- or app-created workspace, or one an `mcp:read`-only token made. It gates EVERY write tool, not only the CAPI export | Ask a workspace owner/admin to enable it in **Settings > Privacy & MCP**. Reads and every write tool's `preview` mode keep working meanwhile |
| <a id="circuit_open" /> `circuit_open` | No | 3+ consecutive failures in the last 30 minutes | Wait for the cooldown, then investigate the underlying failures |

<Callout type="info" title="Idempotency replay is not an error">
Repeating a `confirm` with an idempotency key that was already processed returns a **success** response carrying `replayed: true` and the prior result — no duplicate send, no error.
</Callout>

---

### Input errors

| Code | Retryable | Cause | Fix |
|------|-----------|-------|-----|
| <a id="invalid_input" /> `invalid_input` | No | Invalid or missing parameters | Check the parameter types and required fields |
| <a id="data_unavailable" /> `data_unavailable` | Yes | No data exists for the specified window or entity | Widen the date range or verify the entity exists |

---

### Server errors

| Code | Retryable | Cause | Fix |
|------|-----------|-------|-----|
| <a id="internal_error" /> `internal_error` | Yes | Unexpected server error | Retry once. If persistent, contact support with the `request_id` |

---

## Troubleshooting

### "I get workspace_required but I only have one workspace"

This can happen if your token was created before a workspace was deleted. Try calling `list_workspaces` to see which workspaces your token can access, and pass the `workspace_id` explicitly.

### "Data seems stale or empty"

Check the `meta.data_as_of` field in successful responses. If it's more than a few hours old, your integration may need re-syncing. Go to **Settings > Integrations** and check the sync status.

### "I can't see customer emails"

PII is masked by default. You need all three: `mcp:read_pii` token scope, `include_sensitive: true` in the tool call, and workspace PII mode set to `full_default`. See [Privacy & PII](/docs/mcp/privacy).

### "send_meta_conversions returns insufficient_scope"

This tool requires the `mcp:write` token scope, workspace write-back enabled, and workspace admin role. Check all three conditions.

### "An onboarding write is refused and I do not know which of the three is missing"

The three gates answer with three different codes, on purpose — each has a remedy the other two cannot supply:

| Code | What is missing | Who fixes it |
|------|-----------------|--------------|
| `insufficient_scope` | the token's `mcp:write` grant | you, by re-consenting |
| `writeback_disabled` | the workspace's MCP write-back setting | a workspace owner/admin, once |
| `insufficient_role` | your owner/admin role | a workspace owner/admin |

**Why an approval hand-off does not rescue the third one.** `apply_recommendation` and `send_meta_conversions` let a non-admin propose a write for a human to approve, because the approved write runs later, server-side, through a function Atribu already owns. An onboarding tool's write exists only as a REST call carrying **your** token — there is nothing to run later without either storing your credential or re-implementing the write with different authorization. So for the setup tools the answer is the honest one: someone who holds the role has to run it.

Every one of them carries an `action_url` pointing at the exact page. Run the tool with `mode: "preview"` to see what the call WOULD do — preview clears none of these gates, deliberately, so you can show a user the plan before asking them to change anything.

## Related

<Cards>
  <Card title="Authentication" href="/docs/mcp/authentication">
    Token scopes and rate limits
  </Card>
  <Card title="Available Tools" href="/docs/mcp/tools">
    All 56 tools with parameters
  </Card>
  <Card title="Quickstart" href="/docs/mcp/quickstart">
    Get connected in under 2 minutes
  </Card>
</Cards>

---

# Claude Code Skill

Atribu ships an opinionated [Claude Code skill](https://code.claude.com/docs/en/skills) that teaches Claude how to reason about your attribution data. Once installed, the skill auto-loads whenever you ask about ROAS, campaigns, creatives, or customer journeys — Claude follows the skill's rules and calls the right MCP tools in the right order.

## What the skill does

The MCP server returns raw data; the skill supplies the judgment:

- **Cash-ROAS by default** — the skill teaches Claude that `revenue_type='cash'` is the only thing that counts as ROAS. GHL pipeline deals are "deal value," not revenue.
- **Tool ordering heuristics** — for each common question ("which ads should I kill?", "why is ROAS dropping?", "who's converting?"), the skill specifies which MCP tools to call and in what order.
- **Model guidance** — when to use `last_touch` vs `linear` vs `compare_attribution_models` based on conversion volume and sales cycle.
- **Write-back safety** — mandatory preview → user-confirm → confirm-with-idempotency-key flow for `send_meta_conversions`.
- **Onboarding order** — the golden path from an empty workspace to a live CAPI destination, which step needs which, and which errors mean "a human has to act" (see [Onboarding](#onboarding) below).
- **PII explanations** — when you ask for unmasked data and your token can't provide it, Claude explains exactly what to change instead of silently returning masked values.

---

---

## Onboarding

The skill's write-back section (below) is about a workspace that is already configured. This section is the other half: what to do when it is **not**, which since [#1058](https://github.com/AtribuCore/atribu/issues/1058) an agent can do end to end over MCP.

**Every session starts with `whoami`.** It costs 0 units and now carries a `readiness` summary — how many golden-path steps are done, which is next, and every step that is not done with the reason and the action that unblocks it. An empty dashboard is almost always a missing connection, not a missing result, and this is where that shows.

### The order, and why it is the order

Each step needs the one before it. Skipping is what produces a configuration that looks finished and reports zeroes.

1. **`create_workspace`** — only when `whoami` shows none. You become the owner.
2. **Write-back starts ON for a workspace `create_workspace` just created** — no human has to flip anything before the write tools below will run. Only a workspace created some other way — in the console, or by a Shopify/partner-app install — can have it off: check `whoami`'s `active_workspace.mcp_writeback_enabled`. If it is `false`, say so in one sentence and give the user the URL from the error's `action_url`; do not narrate the whole setting page.
3. **`create_profile`** — one advertiser/brand/client. Everything after this is scoped to it. (Or `create_demo_profile`, when the user wants to see the product working before connecting anything real.)
4. **`issue_tracking_key`** → **`get_tracker_installer`** — mint the key, then hand over the installer **verbatim**. Ask which surface first (`snippet` / `gtm` / `shopify_pixel`); guessing produces an install they cannot use.
5. **`start_connect({ provider })`** — give the user the `url` exactly as returned, then poll `get_handoff({ id })`. Do not open it, do not shorten it, do not summarise it.
6. **`list_outcome_events`** → **`suggest_conversion_definitions`** → **`create_conversion_definition`** — read what the profile actually emits, propose from that, create what the user confirms. Never invent an event name.
7. **`set_attribution_windows`** — only if the customer's sales cycle needs it. It rewrites already-reported numbers, so say so before confirming.
7b. **`sign_dpa`** — Conversion Sync exports nothing until the DPA is accepted. You cannot sign it; hand over the URL. `already_signed: true` is a success — say so and move on rather than offering a link.
8. **`configure_meta_capi`** → **`send_test_event`** — the dataset and its rules, then one synthetic event the customer watches land in Events Manager.

### Rules the skill enforces

- **Preview, show, confirm.** Every write tool takes `mode`. Run `preview`, put its `will_call` and (where present) its dry-run counts in front of the user in their own words, and only then `confirm`. Never confirm in the same turn you previewed unless the user already said yes to that exact thing.
- **A same-name `create_workspace` replay from the same caller is safe within a short window** — it returns `created: false` and the ORIGINAL workspace, never a second one. A different name is always a different workspace. If you did not read the outcome of a call, retry with the identical name rather than guessing a new one.
- **Read the error CODE, not the prose.** `insufficient_scope` → the token needs re-consent. `insufficient_role` → a human with an owner/admin role has to act; re-consenting cannot fix it. `writeback_disabled` → one setting, one URL. `connector_required` → `start_connect`. Each carries an `action_url`; give that link, do not describe the path.
- **Only `cash` counts for ROAS.** When creating a conversion definition for what the customer calls "sales" or "revenue", that is `revenue_type: "cash"`. `pipeline` fills the funnel and contributes nothing to any ratio — a dashboard of zeroes with no error anywhere.
- **A hand-off is the user's to complete.** Poll `get_handoff` every few seconds; do not mint a second one while the first is pending; an `expired` status means mint a fresh one, a 404 means the id is wrong.
- **A partial `configure_meta_capi` is not a success.** It returns `complete: false` and `failed_rules`. Say which conversions will not reach Meta rather than reporting the destination as done.


## Install

The skill is distributed as a Claude Code plugin from the public repo [AtribuCore/atribu-attribution-skill](https://github.com/AtribuCore/atribu-attribution-skill).

<Steps>
<Step>

### Add the marketplace and install the plugin

In any Claude Code session, run:

```
/plugin marketplace add AtribuCore/atribu-attribution-skill
/plugin install atribu-attribution@atribu-attribution
```

The first command registers the marketplace; the second installs the `atribu-attribution` plugin (which bundles the skill).

</Step>
<Step>

### Restart Claude Code

A fresh session guarantees the skill is loaded. You can verify with:

```
What skills are available?
```

You should see `atribu-attribution` in the list.

</Step>
<Step>

### Configure the MCP server

If you haven't already, add the Atribu MCP server ([quickstart](/docs/mcp/quickstart)):

```bash title="Terminal"
claude mcp add atribu --transport http \
  https://mcp.atribu.app/mcp \
  --header "Authorization: Bearer atb_user_YOUR_TOKEN"
```

</Step>
</Steps>

<Callout type="info" title="If the person you are helping has no Atribu account yet">
Do not try to create one for them and do not ask them to go find an API key.
Register a client and build an authorize URL
([API quickstart, step 1](/docs/api/quickstart)), then **hand the URL over and
wait**:

> Open this to connect your Atribu account: `https://www.atribu.app/oauth/mcp/authorize?…`
> Create your account or sign in, approve, and tell me when you are back.

Signup, email confirmation and consent all happen inside that one URL, and it
returns them to your approval screen. Only then exchange the code for a token.
</Callout>

### Updating

When the skill is updated upstream, refresh with:

```
/plugin update atribu-attribution
```

### Other MCP clients

Cursor, Claude Desktop, Windsurf, and other MCP-compatible tools use the same MCP server but don't have Claude Code's plugin system. Copy the contents of [`SKILL.md`](https://github.com/AtribuCore/atribu-attribution-skill/blob/main/plugins/atribu-attribution/skills/atribu-attribution/SKILL.md) into your system prompt or project rules for equivalent behavior.

---

## Skill structure

Inside the public repo, the plugin is laid out as:

```
atribu-attribution-skill/
├── .claude-plugin/marketplace.json     # Marketplace manifest
├── plugins/
│   └── atribu-attribution/
│       ├── .claude-plugin/plugin.json  # Plugin manifest
│       └── skills/atribu-attribution/
│           ├── SKILL.md                # Main instructions
│           ├── references/
│           │   ├── tool-ordering.md    # Detailed heuristics
│           │   └── error-playbook.md   # Error code handling
│           └── evals/evals.json        # Test cases
├── README.md
└── LICENSE                             # MIT
```

The **main `SKILL.md`** stays focused — the quick reference. When Claude needs deeper detail (error handling or tool selection), it reads files in `references/` on demand. This follows Anthropic's recommendation to keep `SKILL.md` lean and lazy-load verbose material.

---

## Evals

The skill ships with `evals/evals.json` — test cases with expected behavior. Each eval is a `{ prompt, expected_output, assertions }` object that documents what Claude should do when the skill is loaded.

Example assertions:

- `"Which ads should I kill?"` → must call `creative_fatigue_check` first, then `top_creatives`
- `"Show me actual email addresses"` → must explain the three-condition PII requirement, not silently return masked data
- `"Send conversions to Meta"` → must call `preview` first, ask for user confirmation, generate an idempotency key, report the `audit_id`

Evals aren't automatically run by Claude Code — they're reference test cases for anyone modifying the skill. Use them as a regression checklist before changing SKILL.md.

---

## Contributing

The skill is open source under MIT. If you find it giving bad guidance, [open an issue](https://github.com/AtribuCore/atribu-attribution-skill/issues) or send a PR at [AtribuCore/atribu-attribution-skill](https://github.com/AtribuCore/atribu-attribution-skill). Released versions are tagged so end users can pin if they want.

## Related

<Cards>
  <Card title="MCP Quickstart" href="/docs/mcp/quickstart">
    Set up the MCP server connection
  </Card>
  <Card title="Available Tools" href="/docs/mcp/tools">
    All 56 MCP tools the skill orchestrates
  </Card>
  <Card title="Write-Back" href="/docs/mcp/write-back">
    The preview/dry-run/confirm flow for send_meta_conversions
  </Card>
</Cards>

---

# Healthcare Mode

Atribu's Healthcare Mode enables HIPAA-compliant attribution for dental, medical, mental health, and other healthcare businesses that advertise online. It protects Protected Health Information (PHI) while still allowing you to measure which ads drive real revenue.

## Why Healthcare Mode exists

Meta and Google ban healthcare ad accounts that send health-related data through their conversion APIs. Healthcare Mode ensures that only safe, scrubbed conversion signals reach ad platforms — while Atribu still tracks the full customer journey internally for your attribution reports.

## How it works

Healthcare Mode changes the export behavior from **blocklist** (send everything, block specific things) to **allowlist** (block everything, only send what you explicitly approve).

| Layer | Standard Mode | Healthcare Mode |
|-------|--------------|-----------------|
| Internal storage | All events stored | All events stored (unchanged) |
| Export policy | Blocklist — send all, block specific paths/params | Allowlist — block all, send only approved event types |
| PII stripping | Configurable per field | All fields stripped (IP, UA, referrer, page title, external IDs) |
| Meta LDU | Optional | Always enabled |
| URL handling | Full URL sent | Not sent, not even the domain |
| Stage transitions | Exportable | Blocked from export |
| Legal gate | Optional | Required — BAA must be signed before any export |

## Legal stack

Healthcare Mode includes a complete legal stack:

- **Data Processing Agreement (DPA)** — governs how Atribu processes personal data on your behalf. [Read the full DPA](/dpa).
- **HIPAA Business Associate Agreement (Annex F)** — the BAA annex that applies when you handle PHI. [Read Annex F](/dpa/baa).
- **Click-wrap acceptance** — accept both documents electronically when enabling Healthcare Mode.

## Who should use Healthcare Mode

Enable Healthcare Mode if your business (or your agency's client):

- Is a healthcare provider (dental, medical, chiropractic, mental health, etc.)
- Is a health plan or insurance entity
- Handles Protected Health Information in any form
- Runs ads where conversion data could reveal health conditions (e.g., booking a dental implant consultation)

<Callout type="warn" title="When in doubt, enable it">
The only downside of Healthcare Mode is reduced ad optimization signal density. The upside is avoiding a HIPAA violation or Meta account ban.
</Callout>

## What's next

- [Set up Healthcare Mode](/docs/healthcare/onboarding) — step-by-step activation guide
- [Configure the export allowlist](/docs/healthcare/export-allowlist) — choose which events reach ad platforms
- [Migrate existing accounts](/docs/healthcare/migration) — switch from standard to HIPAA mode

---

# Healthcare Onboarding

This guide walks you through enabling Healthcare Mode, accepting the legal agreements, configuring the export allowlist, and verifying that your setup is correct.

## Prerequisites

Before you begin:

- You have an Atribu workspace with at least one profile
- You have connected at least one ad platform (Meta or Google Ads)
- You have owner or admin access to the workspace

## Activation

<Steps>

<Step>
### Enable Healthcare Mode

There are two ways to activate:

**Option A — Via Conversion Sync (recommended for first-time setup):**
Navigate to the profile's Conversion Sync page. If the profile is in standard mode, a Healthcare Intercept card appears asking "Do you handle healthcare data?" Click **Yes, enable Healthcare Mode**.

**Option B — Via Settings:**
Go to Settings > Compliance and toggle **Enable Healthcare Mode** on.

Both paths open the DPA/BAA acceptance modal.
</Step>

<Step>
### Accept the DPA and BAA

The modal shows the full text of:
1. **Part I — Data Processing Agreement** — how Atribu processes your data
2. **Part II — HIPAA Business Associate Agreement (Annex F)** — HIPAA-specific obligations

Scroll through the full text, then click **I Accept**. This records:
- BAA status: Approved
- HIPAA eligibility: Eligible
- Signature status: Signed (with timestamp and user ID)
- All three compliance acknowledgements checked automatically
</Step>

<Step>
### Configure the export allowlist

After acceptance, the Privacy & Compliance panel switches from blocklist fields to allowlist checkboxes. Each checkbox represents a conversion definition in your profile.

**Default allowlist:** Only **Payment Received** and **Lead Created** are pre-checked.

Review each event type and only check the ones you are confident do not contain PHI in their payloads. Save when done.
</Step>

<Step>
### Create signal rules

Create signal rules as normal in the rule wizard. Note these HIPAA-specific behaviors:

- Non-revenue event types show an amber warning
- Pipeline stage transitions show a red warning (blocked from export)
- Privacy overrides in the rule wizard are locked — all strip toggles are enforced by the HIPAA floor
</Step>

<Step>
### Verify with a test send

Use the test send feature in Conversion Sync > Deliveries to confirm:
- Events reach the ad platform successfully
- The delivery detail shows privacy filters applied (IP, UA, referrer, external IDs stripped)
- The dashboard shows a green **HIPAA Active** badge
</Step>

</Steps>

## Agency workflow

For agencies managing multiple healthcare clients:

1. Go to **Workspace Settings > Compliance**
2. Enable **Healthcare Agency Mode** — new profiles will default to HIPAA
3. The compliance overview table shows all profiles' HIPAA status at a glance
4. Each profile must still individually accept the DPA/BAA
5. Use the table links to jump directly to each profile's Conversion Sync settings

<Callout type="info" title="Existing profiles are not auto-switched">
Enabling Healthcare Agency Mode only affects newly created profiles. Existing profiles must be migrated individually. See the [migration guide](/docs/healthcare/migration).
</Callout>

---

# Export Allowlist

In Healthcare Mode, Atribu uses an **allowlist** — not a blocklist — to control which conversion events are sent to ad platforms. If an event type is not on the allowlist, it is blocked from export. Period.

## Why allowlist, not blocklist

A blocklist says "send everything except these." An allowlist says "send nothing except these." For healthcare data, the allowlist model is the only safe approach because:

- New event types are blocked by default until explicitly reviewed
- You cannot accidentally expose PHI by forgetting to add something to a blocklist
- The mental model is simple: if you didn't check it, it doesn't go out

<Callout type="warn" title="Blocklist fields are hidden in HIPAA mode">
When Healthcare Mode is active, the blocklist fields (event path blocklist, parameter blocklist) are completely hidden from the UI. They are mutually exclusive with the allowlist — you cannot have both.
</Callout>

## Default allowlist

When you first enable HIPAA mode, two event types are pre-approved:

| Event Type | Why It's Safe |
|-----------|---------------|
| **Payment Received** | Revenue data with hashed identifiers. No health condition information in the payload. |
| **Lead Created** | Contact submission with hashed email/phone. PII is stripped; no diagnosis or treatment context. |

Everything else — including appointment_booked, closed_won, checkout_started, order_placed, and any custom events — is **blocked by default**.

## Configuring the allowlist

1. Go to **Conversion Sync > Privacy & Compliance**
2. In Healthcare Mode, the allowlist section shows checkboxes for each conversion definition
3. Check the event types you want to allow for export
4. Uncheck any you want to block
5. Click **Save**

Each checkbox corresponds to a `conversion_key` in your profile's conversion definitions. The allowlist is stored in `privacy_config.healthcare_export_allowlist`.

## What happens to blocked events

Blocked events are:

- **Still stored internally** — attribution, dashboards, and reports are unaffected
- **Recorded in the export ledger** — with status `skipped` and reason `healthcare_allowlist_blocked`
- **Visible in the Conversion Sync feed** — you can see exactly which events were blocked and why
- **Not sent to any ad platform** — Meta, Google Ads, or any future destination

## Pipeline stage transitions

Pipeline stage transitions (CRM pipeline changes like "Qualified" or "Booked") are **always blocked** in Healthcare Mode, regardless of the allowlist. They cannot be checked or unchecked.

**Why:** Stage transitions often contain pipeline names, deal descriptions, or stage labels that could reveal health conditions (e.g., "Dental Implant Consultation — Qualified").

## Per-event-type review guide

Before adding an event type to the allowlist, ask:

| Question | If Yes... |
|----------|-----------|
| Could the event name or payload reveal a health condition? | Do not add to allowlist |
| Does the event include free-text fields (notes, descriptions)? | Do not add to allowlist |
| Does the event include appointment type or service name? | Do not add — may reveal treatment |
| Is the event purely financial (amount + currency)? | Generally safe to add |
| Is the event a simple form submission (email/phone only)? | Generally safe — PII is hashed before export |

## Interplay with PII stripping

Even for events on the allowlist, Healthcare Mode still strips:

- Client IP address
- User agent string
- HTTP referrer
- Page title
- External IDs (fbclid, gclid, fbc, fbp)
- Page URL (not sent at all, not even the domain)
- Meta LDU flags injected

The allowlist controls **which event types** go out. PII stripping controls **what fields** are in those events. Both layers work together.

---

# Support Runbook

## "Why is my export blocked?"

This is the most common question from healthcare accounts. Here's how to diagnose it.

<Steps>

<Step>
### Check the Conversion Sync feed

Go to **Conversion Sync > Deliveries** and find the blocked event. The status column shows `skipped` and the detail panel shows the skip reason.
</Step>

<Step>
### Read the skip reason

| Skip Reason | What It Means | Fix |
|-------------|---------------|-----|
| `healthcare_allowlist_blocked` | Event type not on HIPAA allowlist | Add it to the allowlist in Privacy & Compliance, or explain why it should stay blocked |
| `healthcare_stage_transition_blocked` | Pipeline stages can't be exported in HIPAA mode | By design — use conversion events instead |
| `healthcare_missing_conversion_key` | Signal rule missing a valid conversion definition | Re-create the rule with a proper conversion definition source |
| `legal_gate_blocked` | BAA/legal requirements not met | Complete legal setup: BAA Approved + HIPAA Eligible |
| `legal_compliance_missing` | No legal record exists | Customer needs to accept the DPA/BAA |
| `hipaa_baa_unsigned` | BAA approved but not signed | Complete the signature workflow |
</Step>

<Step>
### Verify the setup

Confirm these three things:
1. **Privacy mode** is `hipaa` (not `standard` with blocklists)
2. **Legal panel** shows BAA Approved, HIPAA Eligible, all acknowledgements checked
3. **Allowlist** has the expected event types checked
</Step>

</Steps>

## Common mistakes

### Customer switched back to standard mode

If a healthcare customer accidentally switches from HIPAA to standard mode:
- Blocklist fields reappear, allowlist disappears
- No event-type gating occurs
- PII may be sent unfiltered

**Fix:** Switch back to HIPAA in Privacy & Compliance. The allowlist configuration is preserved.

### Rules created before HIPAA mode was enabled

Signal rules created in standard mode are not retroactively blocked. After enabling HIPAA mode:
- Existing rules are subject to the allowlist gate on the next export cycle
- No action needed — the pipeline checks the allowlist at export time, not rule creation time

## Verifying privacy filters

1. Go to **Conversion Sync > Deliveries** and click on a sent event
2. In the detail panel, check:
   - `filters_applied` should include: `ip_removed`, `user_agent_removed`, `url_sanitized`, `external_id_removed:fbc`, etc.
   - `privacy_mode_snapshot.mode` should be `"hipaa"`
   - `legal_gate_snapshot` should show `allowed: true`
3. If `payload_redacted` shows `{ redacted: true, hipaa_mode: true }` — this confirms raw PHI was not stored in the audit log

## What gets stripped in HIPAA mode

| Field | Stripped? | Notes |
|-------|-----------|-------|
| Client IP | Yes | Removed from Meta user_data |
| User agent | Yes | Removed from Meta user_data |
| HTTP referrer | Yes | Removed from custom_data |
| Page title | Yes | Removed from custom_data |
| External IDs (fbclid, gclid, fbc, fbp) | Yes | Removed from Meta user_data |
| URL | Yes | Not sent, not even the domain (`url_removed`) |
| Hashed email (Meta) | Sent | For ad matching — hashed, not plaintext |
| Hashed phone (Meta) | Sent | For ad matching — hashed, not plaintext |
| Hashed email (Google) | Stripped | When strip_external_ids is active |
| Hashed phone (Google) | Stripped | When strip_external_ids is active |
| Meta LDU flags | Injected | Always in HIPAA mode |

## Escalation matrix

| Severity | Trigger | Response |
|----------|---------|----------|
| P1 | PHI confirmed sent to Meta/Google | Disable exports immediately. See [incident response](/docs/healthcare/incident-response) |
| P2 | Healthcare account in standard mode with active exports | Switch to HIPAA. Audit recent exports |
| P3 | Legal setup incomplete, exports blocked | Guide through [onboarding](/docs/healthcare/onboarding) |
| P4 | Allowlist configuration question | Explain the [allowlist](/docs/healthcare/export-allowlist) |

---

# Migration Guide

This guide covers switching an existing profile from standard mode to HIPAA mode, including what changes, what doesn't, and how to roll back if needed.

## Pre-migration checklist

Before switching:

- [ ] List all active signal rules and their event types
- [ ] Identify which rules send non-revenue events (appointment_booked, closed_won, etc.)
- [ ] Inform the customer that some exports will be blocked after migration
- [ ] Confirm the customer's legal team is aware of the DPA/BAA
- [ ] Record current export volume in Conversion Sync > Deliveries for comparison

## Migration steps

<Steps>

<Step>
### Audit current state

Note the current setup:
- Privacy mode (should be `standard`)
- Active signal rules and their source event types
- Recent export volume and patterns in the Conversion Sync feed
- Which destinations are active (Meta, Google)
</Step>

<Step>
### Accept the DPA/BAA

Navigate to **Conversion Sync > Privacy & Compliance** and switch privacy mode to HIPAA. The DPA/BAA acceptance modal appears. Scroll through and accept.

This sets: BAA Approved, HIPAA Eligible, Signed, all acknowledgements checked.
</Step>

<Step>
### Configure the allowlist

The privacy panel now shows allowlist checkboxes instead of blocklist fields.

Review each conversion definition carefully:

| Event Type | Recommendation |
|-----------|----------------|
| Payment Received | Safe — revenue data, no PHI |
| Lead Created | Generally safe — PII is hashed before export |
| Appointment Booked | Review carefully — may reveal health conditions |
| Closed Won | Review carefully — deal names may contain health context |
| Checkout Started | Depends on what is being purchased |
| Order Placed | Depends on product names |

Only check event types you are confident do not leak PHI.
</Step>

<Step>
### Verify exports

Wait for new events to flow through, then check Conversion Sync > Deliveries:
- Allowed event types: `status: sent`
- Blocked event types: `status: skipped`, reason: `healthcare_allowlist_blocked`
- All sent events should show privacy filters in `filters_applied`
</Step>

<Step>
### Communicate to the customer

Inform them:
- Which event types are now exported vs. blocked
- Internal attribution and reporting are unaffected
- The dashboard now shows a green "HIPAA Active" badge
- Pipeline stage transitions are no longer exported
</Step>

</Steps>

## Impact on existing data

| What | Impact |
|------|--------|
| Historical events in events_enriched | No change — already stored |
| Historical conversion_exports | No change — already sent/skipped |
| Active signal rules | Still active, but gated by the allowlist |
| Pipeline stage transition rules | Blocked from exporting (rules still saved) |
| Attribution data | No change — internal attribution is unaffected |
| Dashboard metrics | No change — reads from views/sessions, not exports |

## Rollback

If you need to switch back:

1. Go to **Conversion Sync > Privacy & Compliance**
2. Switch privacy mode from HIPAA to Standard
3. Blocklist fields reappear, allowlist checkboxes disappear
4. The legal gate is no longer enforced
5. All event types become exportable again

<Callout type="warn" title="Rolling back removes all HIPAA protections">
Only switch back if the profile no longer handles healthcare data. The DPA/BAA acceptance record is preserved in the legal compliance table for audit purposes.
</Callout>

## Bulk migration for agencies

1. Enable **Healthcare Agency Mode** in Workspace Settings > Compliance
2. This does not auto-switch existing profiles — each must be migrated individually
3. Use the compliance overview table to track progress
4. New profiles created after enabling agency mode default to HIPAA

---

# Incident Response

This document covers what to do if Protected Health Information (PHI) is sent to an ad platform or otherwise exposed outside the intended scope.

## Severity levels

| Level | Description | Response Time |
|-------|-------------|---------------|
| P1 — Confirmed breach | PHI confirmed sent to Meta/Google | Within 1 hour |
| P2 — Suspected breach | Possible PHI exposure, not confirmed | Within 4 hours |
| P3 — Policy violation | Healthcare account misconfigured, no confirmed PHI sent | Within 24 hours |

## Immediate containment (P1/P2)

<Steps>

<Step>
### Stop all exports (within 15 minutes)

Disable exports for the affected profile:
- Go to **Settings > Integrations** and disconnect Meta/Google destinations, OR
- Go to **Conversion Sync > Destinations** and disable all destination configs, OR
- Set `export_meta_enabled = false` and `export_google_enabled = false` in profile_attribution_settings

If not already active, switch privacy mode to HIPAA.
</Step>

<Step>
### Assess scope (within 1 hour)

Query the conversion_exports ledger to determine:
- How many events were sent during the exposure window
- What data was in the payloads (check `privacy_mode_snapshot.mode` — if `"standard"`, payloads may contain unfiltered PII)
- How many individuals were affected
- Which PHI categories were involved (names, emails, health conditions, appointment types)

Check `payload_redacted` — if it contains full data (not `{redacted: true, hipaa_mode: true}`), PHI may be in the audit log.
</Step>

<Step>
### Document findings

Record:
- Timeline: when misconfiguration started, when detected, when contained
- Affected profiles and workspaces
- Number of events sent with PHI
- Categories of PHI exposed
- Destinations that received the data
- Whether the data can be deleted from the destination
</Step>

</Steps>

## HIPAA breach notification rules

Under the HIPAA Breach Notification Rule (45 CFR 164.400-414):

### 500+ individuals affected

| Notify | Deadline |
|--------|----------|
| HHS (Department of Health and Human Services) | Within 60 days of discovery |
| State attorney general (affected states) | Within 60 days |
| Affected individuals | Within 60 days, by mail or email |

### Fewer than 500 individuals

| Notify | Deadline |
|--------|----------|
| HHS | Annual log submission by March 1 of the following year |
| Affected individuals | Within 60 days |

## Atribu's obligations as Business Associate

Under the BAA (Annex F, Section F-6), Atribu must:
1. Report the breach to the Customer (Covered Entity) without unreasonable delay
2. Provide: nature of incident, categories of affected data, likely consequences, mitigation steps
3. The Customer is responsible for downstream notifications to HHS and individuals

<Callout type="error" title="Atribu reports to the customer, not to HHS directly">
As a Business Associate, Atribu's notification obligation is to the Covered Entity (your customer). The customer then determines whether to notify HHS, state authorities, and affected individuals.
</Callout>

## Post-incident review

After containment and notification:

1. **Root cause analysis** — Why was the profile in standard mode? Was Healthcare Mode disabled accidentally? Did the onboarding flow miss the healthcare use case?
2. **Preventive measures** — Should the workspace be in Healthcare Agency Mode? Are there other profiles that need migration?
3. **System improvements** — Update detection logic, review allowlist defaults, consider additional safeguards
4. **Documentation** — Record the incident in audit_log_events, update legal compliance notes, file per company policy

---