Partners & OAuth Apps
The surface a sibling product integrates against: OAuth-app credentials, zero-bounce provisioning, and the WhatsApp join.
This page is for a consumer application — a product that provisions Atribu profiles for its own merchants and reads their attribution, rather than a business measuring its own ads. Vitrina is the reference consumer.
If you are an agency, a developer or an AI agent working on your own account, you want Credentials and the API quickstart instead.
The credential
POST /oauth/token
POST /oauth/revokeAn OAuth app authenticates with client_secret_basic or client_secret_post
and receives an atb_live_… API key carrying a tracked oauth_app_id.
POST /oauth/revoke is RFC 7009
token revocation.
Use https://www.atribu.app as your base URL. That is this spec's
servers[0].url, and it is not interchangeable with api.atribu.app:
POST /oauth/token, POST /oauth/revoke and POST /api/v1/events are served
only on www and answer 404 on api.atribu.app (verified 2026-09-15). A
404 from a token endpoint reads like a wrong path, so it is worth pinning the
host once rather than debugging it per call.
This is a different OAuth surface from the one an AI agent uses. The agent
bootstrap is /oauth/mcp/* — dynamic client registration, PKCE, a human
consent screen, an atb_user_… token. See the
API quickstart.
Connect scopes
Two connect scopes carry a Partner App's whole relationship to a Profile.
Both are connectionless — the same class as analytics / analytics_pii
/ attribution_write below, not tied to one provider hand-off the way
whatsapp / instagram / email / calendar are.
| Connect 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:
PUT /api/v1/profiles/{profileId}/entitlements/atribu_attribution{ "enabled": true }{ "enabled": true } upserts the profile's grant to {connections, attribution} — replace semantics, clearing revoked_at if the grant had
been revoked. The call itself never returns a secret: mint a fresh key
against the widened grant afterward, grant_type=client_credentials, the
same way you already do for rotation.
{ "enabled": false } narrows the grant back to {connections} rather
than revoking it outright — a full revoke would have taken the free connect
rail down along with the Add-on. The connect broker tier keeps working.
Every API key minted under the wider {connections, attribution} grant is
revoked as part of the narrowing, so an Ads-scoped key stops authenticating
inside the ~10 second auth cache window. Mint a fresh core-tier key against
the narrowed grant afterward — the same grant_type=client_credentials
rotation you run after ON.
Reads stay open on the connect broker tier
A connect-broker-only profile is never a 403 wall. Every read route
(analytics, campaigns, conversions, …) stays reachable and simply answers
empty — a connectivity-only profile has no attribution numbers yet, for any
credential asking. What gates is the write side: the exports/* writes,
POST /api/v1/events, the attribution writes, and the ad-platform writes all
answer 409 invalid_state (code entitlement_required, naming
atribu_attribution) on an app-provisioned profile with the Add-on off — for
any principal, including your own merchant's signed-in session, not only a
delegated key. A profile a human provisioned directly never sees this at all
(ADR 0021).
Two failures to branch on when a write 403s or 409s:
| 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.
{
"external_ref": "dealer_8812",
"scopes": ["connections"]
}For an external_ref-mode app this has no browser step — the same
auto-allow naming the profile a consent lands
on already describes (ADR 0020). The
call narrows the grant to {connections}. Mint a new key against it, swap it
into your integration, then revoke the old one — the mint → swap → revoke
rotation you already run.
The old key keeps working during the swap
While your previous grant is still live, the connect rail also accepts a key
minted under the old attribution_write grant, so a merchant mid-swap never
loses connect access. Once you revoke the old key, only connections /
attribution scopes work there.
Verify by exercising the connect rail with the new key — list connections,
start a hand-off, read sync status — and confirming nothing on it answers
403.
Provisioning a merchant
POST /api/v1/profilesThe app-credential branch provisions end to end: it creates the workspace if needed, the profile, the tracking key, and returns the credential the consumer stores for that merchant. It is idempotent on a natural key, so a retried provisioning is not a second profile.
A partner-provisioned profile is deliberately not attribution-entitled
Such a profile answers blocked on readiness's
attribution_enabled step — the rest of the checklist is still evaluated and
still reports what it finds. "Well configured" and "will never attribute
anything" are both facts a consumer needs. Grant the entitlement below to
unblock it.
Archiving a profile you provisioned
DELETE /api/v1/profiles/{profileId}Retires a merchant your app provisioned, and its dealer workspace with it. Terminal — there is no un-archive.
The same client_secret_basic credential as provisioning. The profile must be
one your app provisioned — an unknown id, another app's profile, or one a
human created all answer 404, identically.
Provisioning gives every merchant a workspace of its own, so archiving the
profile and archiving its workspace are the same transaction: every
membership is removed, pending invitations are revoked, the workspace's API
keys are revoked, its webhook subscriptions are paused, and its non-audit
data is purged — the same effect
DELETE /api/v1/workspaces/{workspaceId} has for a
signed-in owner. Every live grant on the profile — any app's — and every
delegated API key each one minted are revoked as part of the same call.
{
"data": {
"profile_id": "…",
"workspace_id": "…",
"archived": true,
"workspace_archived": true
}
}A 409 means the workspace is not exclusively yours anymore
If a human has since added a second live profile to that workspace, this
call answers 409 invalid_state rather than silently widening to destroy a
profile your app never provisioned. Archive or move the other profile first.
Idempotent. The first call and every later call both answer 200 with
archived: true — a DELETE that 404s an already-archived profile would make
a retry look like a failure.
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:
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.
Switching modes is a one-way door for existing merchants
Apps stay on profile_resolution: "email" until Atribu switches them, and an
external_ref claim on an email-mode app is ignored — so you can start
sending it before the switch. But from the moment the switch lands the claim is
mandatory, and every profile you still expect to reach must already carry a
provisioned_external_ref. Profiles created by the old email path carry none;
adopt them through the provisioning API first, or their consents start
answering unknown_external_ref.
Every scope works this way — whatsapp, instagram, email, calendar,
calendar_read, and the connectionless connections / attribution /
analytics / analytics_pii / attribution_write. Nothing after consent
changes: the authorization code carries the profile
immutably, every connect page reads it back, and the exchange mints the key
against it.
Adopting a profile that predates external_ref (#1415)
POST /api/v1/profiles/{profileId}/adoptProvisioning 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.
{
"external_ref": "dealer_8812",
"scopes": ["analytics"]
}Stamps external_ref, upserts the profile-level grant with scopes (the same
validation POST /api/v1/profiles applies), and writes an audit event, all in
one call. Idempotent on the profile: a repeat call with the same
external_ref answers 200 with created: false; only the call that
performs the stamp answers 201. A repeat with different scopes still
updates the grant — the same re-consent shape provisioning uses. A profile
that already carries a different external_ref answers 409 — including
when a concurrent POST /api/v1/profiles claims that exact ref for a
different profile while your adopt call is in flight; the underlying unique
index arbitrates either way.
Not part of the Node SDK
Like POST /api/v1/profiles itself, this is an app-credential call your
backend makes directly with client_id/client_secret — there is no
@atribu/node method for it. The request/response shapes are in the
OpenAPI document and its
generated types.
Declaring the attribution entitlement (#1418)
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.
{ "enabled": true }Today {feature} is only ever atribu_attribution — the path takes a
parameter rather than a fixed segment because profile_entitlements is a
per-feature table on the engine side, so a second feature gets a value here
rather than a new route.
Same client_secret_basic credential as POST /api/v1/profiles and the
members endpoints above — never a session token, never an atb_live_ key.
The check is the same stamp: {profileId} must carry provisioned_by set to
your client_id. Any other profile — unknown, another app's, or archived —
answers 404, identically; this endpoint never confirms that a profile id
exists or which app owns it.
Turning it on starts what a human-entitled profile gets: the ad-platform
sync's performance-shaped work (insights/spend, messaging metrics,
attribution, conversions, exports) resumes, and Atribu immediately enqueues
the standard backfill window for every ad-platform connection the profile
already holds. You do not also need to call
POST /api/v1/connections/sync.
Turning it off returns the profile to connectivity-only mode: campaign/ad structure and conversational configuration (the click-to-WhatsApp ad greeting) keep syncing nightly, but the performance-shaped work stops. Nothing is deleted.
Connectivity-only mode, precisely (ADR 0021)
While atribu_attribution is off, a meta_ads or google_ads connection on
this profile still gets its nightly (and on-demand) structure sync —
campaigns, ad sets, ads and creatives: names, status, the ad set's
destination, and the creative's call-to-action type and greeting (the source
the click-to-WhatsApp ad greeting read prefers). It does not get insights,
spend, messaging metrics, attribution recompute, conversion sync, or exports —
so GET /api/v1/connections honestly reports delivers_spend: false for such
a connection, because no spend row is ever written for it. This is the
free-tier state every profile you provision starts in; turning the entitlement
on is what upgrades it to full attribution.
{
"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)
GET /api/v1/workspaces/{workspaceId}/members
PUT /api/v1/workspaces/{workspaceId}/members
DELETE /api/v1/workspaces/{workspaceId}/members/{userId}Once a merchant's workspace exists, your app is the source of truth for who
is on its team — Atribu is the headless engine underneath it, and sends the
merchant's people no email at all. So this is not an invitation: PUT
states a membership and Atribu mirrors it. Calling it again with the same
body changes nothing.
All three routes take the same client_secret_basic credential as
POST /api/v1/profiles — never a session token, never an atb_live_ key.
Only workspaces your app provisioned
The check is the same stamp POST /api/v1/profiles writes: the workspace must
hold a profile with provisioned_by set to your client_id. A bare workspace
from the app-credential branch of POST /api/v1/workspaces qualifies once it
holds one such profile. Any other workspace — one you did not provision, or one
that does not exist — answers 404, identically.
PUT finds-or-creates a passwordless user for email (the same lookup
provisioning uses for the merchant owner) and sets their workspace_role —
admin, analyst, or guest; never owner, see below. admin and analyst
reach every profile in the workspace regardless of profiles; guest reaches
only the profiles you name there.
{
"email": "[email protected]",
"workspace_role": "guest",
"profiles": [
{ "profile_id": "…", "profile_role": "viewer" },
{ "profile_id": "…", "profile_role": "manager", "permissions": ["view:dashboard", "view:attribution"] }
]
}profiles, when present, is that person's complete profile access in this
workspace: every profile listed is granted, and every active profile
membership not listed is removed. Omit profiles entirely to change only the
workspace role and leave existing profile access untouched. permissions
absent means the role's default bundle; present means exactly that list — an
unknown key is a 400 naming it, never silently dropped.
The response is 201 the first time this reconciles a new membership, 200
on every later call that updates one — the same idempotent-on-email shape
POST /api/v1/profiles uses for external_ref.
The owner is off limits
The workspace owner was set by provisioning and is the row RLS resolves a
direct sign-in through. Assigning owner (the request does not even offer the
value), changing the owner's role, or removing the owner all answer
409 invalid_state.
DELETE soft-removes the workspace membership and every profile
membership that person holds in this workspace — rows are kept for audit, not
erased. It is idempotent: calling it on someone already removed, or never a
member, answers 200 {"removed": false}, not a 404.
Disconnecting a merchant's connection
POST /api/v1/connections/{id}/disconnectYour atb_live_… key can disconnect a Meta, GoHighLevel, Stripe or other
connection on a profile your app provisioned — the one case where there is
no Atribu person to sign in and do it themselves. Same effect as a merchant
disconnecting it in the Atribu console: the provider's push channel is
stopped, the row is deleted, connection.revoked is delivered to your
webhook subscriptions, and an audit event is written. There is no Atribu
person behind the write, so its actor_user_id is null; the metadata names
your app instead.
Your own app-provisioned profiles only
The check is the same stamp provisioning writes:
profiles.provisioned_by must equal your client_id. A human-owned profile,
or one another app provisioned, answers 403 even with a valid
connections:write key — identical to the refusal a direct admin key has
always gotten. A connection mid-sync is 409; wait for it to finish.
This is not DELETE /api/v1/connections/{id} — that route gives up your
app's own grant on a connection and leaves the merchant's connection in place.
This one removes the connection itself.
Returning the merchant to your app
A consumer's connect flow bounces the merchant through Atribu and back. The
return_url's origin must be one your app can receive the bounce on: any
origin from your app's redirect_uris works automatically, since Atribu is
the OAuth provider and a redirect_uris entry is already your own page.
allowed_return_origins is only for narrowing that set, or adding an origin
your redirect_uris don't cover — an origin outside both is refused rather
than redirected to. When one consent exposes several accounts, the merchant
lands on the pending-selection picker — see Connections.
Registering the app
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-secretRegistration is admin-gated — it is Atribu operations work, not self-serve.
The two rotations are separate on purpose: client_secret authenticates the
app at the token endpoint, jwt-secret signs the id_token_hint that carries
the merchant's identity into a consent detour. Rotating one must not invalidate
the other.
GET reads an app's settings back — including profile_resolution, which
PATCH is also how it is switched ("email" | "external_ref"). Neither ever
returns the client-secret hash or either signing secret: those are shown once,
at create and rotate time.
Both also return the app-level DPA (#1668): dpa_status (not_accepted |
accepted | withdrawn), dpa_version, dpa_accepted_at, dpa_accepted_by
— read only, because accepting a contract is a signed-in person's act, not an
operations call — and owner_workspace_id, the partner's own Atribu workspace
whose owners and admins accept it. POST and PATCH set
owner_workspace_id. See Legal → The DPA for a partner app.
The Shopify equivalents (/admin/shopify-apps) register a merchant's
custom-distribution app and its OAuth scopes; DELETE is a kill-switch.
Healthcare profiles never receive customers:read or visitors:read (policy S5)
A profile is healthcare-family when its workspace is marked
is_healthcare_agency, or when its privacy_mode is platform_safe or
hipaa. Your app's keys for such a profile never carry customers:read, the
person-level read behind /api/v1/customers and /api/v1/customers/{id}/journey,
nor visitors:read, the per-visitor browsing histories behind /api/v1/visitors.
For a clinic, a journey is a named patient plus the treatment pages they
browsed, and an anonymous visitor becomes a named patient as soon as they
identify. That is health information about an identifiable person (Ley 21.719
Art. 16 bis), and it is the context Platform-Safe removes before Meta sees it.
- Minting.
POST /oauth/tokenleavescustomers:readandvisitors:readout of the key. This applies to theattributionbundle too, and nothing else in the bundle changes:campaigns:read,conversions:read,exports:read,campaigns:applyand the connect rail all stay. Read the token response'sscopeto see what the key holds. If Atribu cannot read the profile's healthcare settings, it mints no key and answersserver_error. Retry the call. - Granting. On a healthcare-family profile,
scopes: ["analytics_pii"]onPOST /api/v1/profilesorPOST /api/v1/profiles/{profileId}/adoptanswers403 healthcare_scope_forbidden, and the grant stays as it was. Ask foranalyticsinstead. - Keys already issued keep the scopes they were minted with. Rotate (mint → swap → revoke) to move a clinic onto the narrowed set.
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:readboth answer the fullJourneyEventpage (URL, path, device, geo, session), exactly as before. - Without it both answer
JourneyEventProjectedrows:
{
"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. Nothing is ever created.
Identity resolution stays inside Atribu: you send your key, and the touches come back keyed by it.
Platform-safe for sensitive verticals (#306, #1669)
A profile whose outcomes carry health or other sensitive context runs in
privacy_mode: "platform_safe". Atribu then sends Meta a scrubbed payload:
origin-only URLs, the allowlisted custom_data fields, hashed identities,
neutral event names, no health context. Hashed email and phone are kept: Meta
requires them hashed and they are not what its health filter reads.
Where it goes is platform_safe_target. Meta categorizes a data source
(the domain and what the business offers), not single events, and a dataset
Meta has not restricted accepts Lead, Schedule and Purchase with value. So by
default (existing) the scrubbed feed goes to the profile's ordinary Meta
CAPI destination(s): the dataset the merchant's live ad sets already optimize
on. Nothing is recreated and Meta's learning is kept. The Clean Dataset is
the remediation for a dataset Meta has restricted (clean): a fresh dataset
in the merchant's ad account, and then the only Meta feed. Every other Meta CAPI
destination is suppressed (routing: "suppressed_platform_safe"), and a clean
profile with no Clean Dataset sends Meta nothing. Read the target on
GET /api/v1/conversion-sync/catalog (settings.platform_safe_target) and on
GET /api/v1/exports/destinations.
All calls below need the attribution bundle's exports:write /
exports:read, so the profile's attribution entitlement must be on.
1. Switch the profile. PUT /api/v1/conversion-sync/catalog with
privacy_mode: "platform_safe" (re-send the current privacy_config; the PUT
replaces it). On the default existing target the profile now sends the
scrubbed feed to its ordinary Meta destination, once the DPA is
accepted (below).
2. Check the dataset in Events Manager, and the event names. Meta exposes no
API field for a dataset's category, so a person checks it:
GET /api/v1/conversion-sync/wiring returns the steps in
platform_safe.categorization.checklist (Events Manager → Datasets → Settings →
Manage data source categories, then Diagnostics for blocked Lead / Schedule /
Purchase). platform_safe.event_mapping lists the event name each rule sends to
each destination. Under existing that name must be the one the live ad sets
optimize on; change it on the rule (platform_event_name_overrides) or disable
the rule. When the merchant's own pixel or CRM already fires the same name on the
same dataset, double_count_risk is true (other_sources_7d foreign events
over 7 days, from the dataset's own counts). Pick a name the pixel does not send,
or disable that rule, or it counts twice.
3. Only when Meta restricts the dataset: switch to a Clean Dataset.
POST /api/v1/conversion-sync/platform-safe/target with {"target": "clean"}.
It runs the provision below, moves the feed to the Clean Dataset only if one
exists afterwards, and returns ad_sets_to_recreate: the live ad sets still
optimizing on the old dataset, each with its Clean Dataset target. Apply them
with POST /api/v1/conversion-sync/platform-safe/repoint. If the provision
answers action_required, the target stays existing (switched: false) so
the profile keeps sending instead of sending nothing. Going back
({"target": "existing"}) is refused with 409 clean_dataset_has_live_ad_sets
while ad sets on the Clean Dataset are still delivering (or Meta cannot be read
to check), unless you send confirm_live_ad_sets: true.
The provision itself is POST /api/v1/conversion-sync/platform-safe/provision
with an empty body. It is safe to call on every setup run, and a Clean Dataset
that exists afterwards sets the target to clean:
| 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). 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):
targetandtarget_destination_ids(the destinations that receive the feed).resultsholds only their rows; the rest are listed insuppressed_destination_ids, without verdicts.- Under
existing: the rows keep their own remediation (an in-placewire_optimizationis valid on the same dataset), there is no provision and no recreate step, and the one Platform-Safe remediation isswitch_to_clean_dataset. - Under
clean:platform_safe_dataset: "missing"with remediationprovision_platform_safe_dataset, or"provisioned"with theclean_dataset_id. A rule row that would have saidwire_optimizationsaysrecreate_ad_setinstead, with thetargetandad_set_ids.ad_sets_to_movelists every live ad set still optimizing on the old dataset, with its target and the Ads Manager steps. It is read only on?force=1and onPOST /api/v1/conversion-sync/wiring/recheck, and isnullon a cached read. dpa_status: "not_accepted"with remediationsign_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, andsign_dpadoes 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 tocleanif 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 two routes:
| Route | Confirmation flag | Applies to |
|---|---|---|
POST /api/v1/conversion-sync/platform-safe/repoint | confirm_recreate: true | A Platform-Safe profile whose platform_safe_target is clean: moves its live ad sets onto the Clean Dataset's conversions. |
POST /api/v1/conversion-sync/wiring/ad-sets | confirm_duplicate_swap: true + duplicate_swap_mode (switch or run_both) | Any profile: points ad sets at a rule's conversion event. |
Both work in two rounds. The first call (no confirmation) re-points what Meta
allows and hands back the frozen ad sets in needs_recreate /
needs_duplicate_swap, untouched. Show the consent screen, then repeat the
call with the confirmation flag and the person's consent. Each rebuilt ad
set is copied paused, its ads are copied, the copy goes live, and the original
is paused last (never deleted; on run_both it keeps running).
The consent contract. With an API key (your OAuth-app key included) or an MCP user token, a request that confirms a rebuild must also carry:
{
"ad_set_ids": ["120200000000000002"],
"confirm_recreate": true,
"consent": {
"version": "2026-09-23.duplicate-swap.v1",
"text_hash": "0168f7ab61559b4e1b8d6b8b1456d19cf58f546c7d4a4a6a7be4375a0a1dadff",
"accepted_by": "your-user-8f2c",
"accepted_at": "2026-09-23T14:05:00Z"
}
}version: the current consent text version,2026-09-23.duplicate-swap.v1.text_hash: SHA-256 (lowercase hex, UTF-8) of the consent text exactly as your screen rendered it, in one of the two locales below.accepted_by: your own identifier for the person who accepted (your user id is enough). Atribu stores it in the audit trail beside the rebuild.accepted_at: when they accepted (ISO 8601), at most 24 hours before the call.
Render the text verbatim, one locale, as plain text (- lines are list
items). Do not paraphrase it: a paraphrase has a different hash and is refused.
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
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 | run_both was chosen: the original keeps running beside its copy. |
A signed-in Atribu session needs no consent object: the dashboard shows the
same consequences in its own consent panel. On the repoint route, the automated
rebuild applies only to a profile whose platform_safe_target is clean;
under existing a key or MCP token gets 409 invalid_state naming the
switch_to_clean_dataset remediation (POST /api/v1/conversion-sync/platform-safe/target
with {"target": "clean"}), because the ad sets already receive the scrubbed
feed and there is nothing to rebuild. The guided steps in the wiring read's ad_sets_to_move stay available as the
manual alternative.
Acting on a merchant's ads
Four routes let your app act on — and explain — a merchant's Meta ads with the merchant's delegated key. None needs an open recommendation.
POST /api/v1/ads/{id}/pause campaigns:apply tier confirm
POST /api/v1/ads/{id}/resume campaigns:apply tier confirm
POST /api/v1/adsets/{id}/budget campaigns:apply tier money, ±50 %
GET /api/v1/conversions/{id}/journey conversions:read{id} on the three writes is Meta's own id — the platform_id of a
GET /api/v1/campaigns?level=ad|ad_set row, the same id
recommendation.target.ad_id / target.adset_id carry. The writes are the same
action-layer writes POST /api/v1/recommendations/{id}/apply performs: one audit
row in meta_action_log per attempt on a resolved connection (refusals from that
point on included — the read-only-grant refusal, the guardrail, a Meta error),
the Meta state captured before the write, and an undo through
POST /api/v1/conversion-sync/actions/{action_id}/rollback (or, for a pause,
the resume route). A rollback restores the row's pre-state only while the object
still carries what that row wrote; after a later change it answers
not_reversible with reason_code: "state_drifted" and writes nothing — roll
back the latest change instead. Like the recommendation apply, campaigns:apply is
entitlement-gated: the profile needs the atribu_attribution Add-on.
Idempotency-Keyis 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 is409 idempotency_key_conflict; a retry that arrives while the first is still running is409 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 lacksads_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 a200withoutcome: "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 as429 rate_limit_exceededwithRetry-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_bydefaults to the workspace's owner — the userPOST /api/v1/profilescreated for the merchant — as long as that user is still its active owner. The same default now applies toPOST /api/v1/recommendations/{id}/apply, so you no longer look that id up throughGET /api/v1/workspaces/{id}/members. An explicitapplied_byis 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_basisisapplied_by,personorapp_shadow_owner(on the recommendation apply:applied_byorapp_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 as422 validation_error. An ad set whose campaign owns the budget, or on a lifetime budget, is409 invalid_state.new_daily_budget_minoris in the ad account's minor units — for CLP that is pesos.
GET /api/v1/conversions/{id}/journey answers the visitor timeline behind one
conversion — the same page as GET /api/v1/customers/{id}/journey — addressed
by the outcome_event_id your POST /api/v1/events response returned, a
conversions.id, or your own event key (the Idempotency-Key you ingested
with, URL-encoded). With customers:read, meta.conversion.customer_profile_id
names the customer it resolved to, and a conversion ingested with no identity at
all answers 409 invalid_state. Without it, the answer is the projection
described in Journey reads without customers:read,
keyed by your customer_key.
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:
- Do not degrade a channel on
spend_unavailable. A connection'sstatus,status_reason, identity andsynccounters are core-side and are exactly as trustworthy on such a response as on any other. Re-readdelivers_spendlater and keep whatever you last knew. - A readiness step you could not measure is
degraded, nevermissing.missingwould tell a merchant to install a tracker they may already have installed.summary.doneon such a response is a floor, not a verdict, and the step'snextis to retry the same call. - 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_clidand a WhatsApp dataset, the event goes to the dataset linked to the profile's WhatsApp Business Account (one per WABA, created or fetched withPOST /{waba_id}/datasetwhen the merchant connects WhatsApp and Meta Ads) withmessaging_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 aplatform_safeprofile. - Without a click id, or while the merchant's WhatsApp connection is
disconnected, it is sent to the website dataset as
chat: Meta rejects abusiness_messagingevent without a messaging click id (and the whole batch with it). The delivery's filters then readaction_source_degraded:business_messaging_to_chat.
Only the top-level field counts: properties.action_source is ignored. A value
outside the list is 400 invalid_parameter. Omit the field to keep the previous
inference (business_messaging on a WhatsApp dataset with a click id, else
website when the event has browser signals, else system_generated, with the
page URL). Every entry of GET /api/v1/exports/ledger carries the
action_source its payload actually had.
POST /api/v1/partner/wa-joins
Joins an inbound WhatsApp message to the web session that produced it — the
read side of the web→WhatsApp attribution path. Its external_id hashes facts
about the message (merchant phone, sender phone, timestamp, normalized
text), all of which a retry resends verbatim, so a redelivery is the same
conversation rather than a second one.
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.
| 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/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.