Atribu
API Reference

Partners & OAuth Apps

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

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

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

The credential

Endpoints
POST /oauth/token
POST /oauth/revoke

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

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

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

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

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

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

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

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

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

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

Reads stay open on the connect broker tier

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

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

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

Migrating a profile provisioned before #1455

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

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

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

The old key keeps working during the swap

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

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

Provisioning a merchant

Endpoint
POST /api/v1/profiles

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

A partner-provisioned profile is deliberately not attribution-entitled

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

Archiving a profile you provisioned

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

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

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

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

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

A 409 means the workspace is not exclusively yours anymore

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

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

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

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

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

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

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

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

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

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

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

Switching modes is a one-way door for existing merchants

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

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

Adopting a profile that predates external_ref (#1415)

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

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

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

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

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

Not part of the Node SDK

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

Declaring the attribution entitlement (#1418)

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

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

Request body
{ "enabled": true }

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

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

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

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

Connectivity-only mode, precisely (ADR 0021)

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

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

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

Reconciling a merchant's team (#1252)

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

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

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

Only workspaces your app provisioned

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

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

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

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

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

The owner is off limits

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

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

Disconnecting a merchant's connection

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

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

Your own app-provisioned profiles only

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

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

Returning the merchant to your app

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

Registering the app

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

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

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

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

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

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

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

  • Minting. POST /oauth/token leaves customers:read and visitors:read out of the key. This applies to the attribution bundle too, and nothing else in the bundle changes: campaigns:read, conversions:read, exports:read, campaigns:apply and the connect rail all stay. Read the token response's scope to see what the key holds. If Atribu cannot read the profile's healthcare settings, it mints no key and answers server_error. Retry the call.
  • Granting. On a healthcare-family profile, scopes: ["analytics_pii"] on POST /api/v1/profiles or POST /api/v1/profiles/{profileId}/adopt answers 403 healthcare_scope_forbidden, and the grant stays as it was. Ask for analytics instead.
  • Keys already issued 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:read both answer the full JourneyEvent page (URL, path, device, geo, session), exactly as before.
  • Without it both answer JourneyEventProjected rows:
{
  "touch_id": "uuid",
  "kind": "web_touch",
  "at": "2026-09-20T14:02:11Z",
  "channel": "Paid Social", "source": "ig", "medium": "paid",
  "campaign": { "external_id": "1203…", "name": "Spring promo" },
  "ad_set":  { "external_id": "1203…", "name": "Lookalike 1%" },
  "ad":      { "external_id": "1203…", "name": "Video A", "creative_thumbnail_url": null },
  "click_id_kind": "fbclid",
  "join_method": "link_token",
  "is_credited": true, "credit_share": 1, "credit_model": "last_touch", "in_window": true
}

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

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

These fields are absent from the JSON, not null.

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

The person. GET /api/v1/customers/journey?customer_key= names the person by your own customer id, so you never store Atribu's customer_profile_id. The page covers every touch and outcome of that person, across devices and repeat conversions, oldest first, with the same cursor / limit (max 100) / model parameters. meta.person is {customer_key, conversion_count, touch_count, first_touch_at, last_touch_at, conversions[]} with the newest 50 conversions; with customers:read it also carries first_name, last_name and identifiers. ?email= or ?phone= instead of customer_key need customers:read and answer 403 insufficient_scope without it. A merged customer resolves to the one it was merged into; an unknown key, an erased customer and another profile's customer are all 404. 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:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

SHA-256: 0168f7ab61559b4e1b8d6b8b1456d19cf58f546c7d4a4a6a7be4375a0a1dadff

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

SHA-256: 7e5d0f63d2baac8b33b9f6a7f7a31b74b0121a15654d89b9dce674e29f983a5c

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

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

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

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

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

Acting on a merchant's ads

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

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

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

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

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

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

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

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

Three rules for a consumer:

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

Which action_source to send

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

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

How business_messaging is exported:

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

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

POST /api/v1/partner/wa-joins

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

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

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

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

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

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

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

GET /api/v1/partner/whatsapp/ads

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

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

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

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

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

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

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

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

MethodPathWhat it does
POST/api/v1/admin/oauth-appsCreate a consumer OAuth app
GET/api/v1/admin/oauth-apps/{id}Read a consumer OAuth app
PATCH/api/v1/admin/oauth-apps/{id}Update a consumer OAuth app
DELETE/api/v1/admin/oauth-apps/{id}Suspend a consumer OAuth app (kill-switch)
POST/api/v1/admin/oauth-apps/{id}/rotate-client-secretRotate the OAuth app's client_secret
POST/api/v1/admin/oauth-apps/{id}/rotate-jwt-secretRotate the OAuth app's id_token_hint HS256 signing secret
GET/api/v1/admin/oauth-apps/{id}/usageOne consumer app's API usage
GET/api/v1/admin/pii-grantsWho can read personal data through this API
GET/api/v1/admin/shopify-appsList registered Shopify apps
POST/api/v1/admin/shopify-appsRegister a merchant's custom-distribution Shopify app
PATCH/api/v1/admin/shopify-apps/{id}Update a Shopify app's OAuth scopes
DELETE/api/v1/admin/shopify-apps/{id}Revoke a Shopify app (kill-switch)
POST/api/v1/partner/wa-joinsJoin an inbound WhatsApp message to the web session that produced it
GET/api/v1/partner/whatsapp/adsList the profile's click-to-WhatsApp ads
GET/api/v1/partner/whatsapp/ads/{ad_id}/welcome-messageRead a click-to-WhatsApp ad's welcome message
POST/oauth/revokeRFC 7009 token revocation
POST/oauth/tokenOAuth 2.0 token endpoint

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

Next steps

On this page