Atribu
API Reference

Changelog

Every developer-facing change to the /api/v1 surface, newest first

Developer-facing changes to https://api.atribu.app/api/v1/**. See the deprecation policy for what a removal looks like before it happens.

Active deprecations

OperationDeprecatedSunsetSuccessor
POST /api/v1/goals2026-09-052026-12-04/api/v1/goals/definitions

Changes

Each line links the issue that carries the full reasoning — what broke, what to change, and why the decision went the way it did.

2026-09-30

  • Added · #1836 — learning_stage_status (Meta's ad-set learning phase: LEARNING, SUCCESS or FAIL) and campaign_objective (the parent campaign's objective) on GET /api/v1/ads/{id}?level=ad_set, read from Atribu's nightly-synced copy, never live from Meta. Null at every other level.
  • Fixed · #1831 — api.atribu.app now answers every operation that was served only on www.atribu.app (the /api/v1/partner/* routes, POST /api/v1/events, /oauth/token, webhooks, calendar, email, Instagram and more) instead of 404, by forwarding the request unchanged to the same handler. www.atribu.app remains the canonical base URL.
  • Fixed · #1828 — outcome.recorded webhooks are now enqueued in the replay's order within each delivered batch (they could be enqueued out of order inside a batch of up to 200). No stored data changed; the replay's order is unchanged.
  • Fixed · #1827 — Campaign conversions are paginated. The response used to stop at 50 conversions without saying so; it now takes limit (1–200, default 50; anything else is a 400) and cursor, and returns pagination: { has_next, cursor }. Follow cursor while has_next is true to read every conversion; a page can hold fewer than limit when paths are long. Without a cursor you get the same first page as before.
  • Added · #1827 — Each campaign conversion row carries is_first_payment: whether the payment is the customer's first, the flag behind the first-payment figures. Null when the conversion is not a payment. Visible under campaigns:read.
  • Breaking · #1825 — Campaign, ad set and ad counts now include only conversions CREDITED to the entity. Under the single-touch models (last_touch, first_touch, last_non_direct) a conversion used to be counted under every campaign / ad set / ad its path crossed, and again in the unattributed remainder. So outcome_count, outcome_count_first_payment and attributed_outcome_count go DOWN for those models, cac / cac_first_payment go UP by the same ratio, and GET /api/v1/campaigns/unattributed's counts go down. Rows + remainder now add up to the attributed total. Values (outcome_value, roas) do not change. Under linear, time_decay and atribu a conversion split across two entities still counts under both.
  • Added · #1825 — attributed_outcome_count (conversions of every kind credited to the entity, refunds excluded; not cash, never a ROAS/CAC denominator) and attributed_outcomes_by_type (the same count split by conversion type, e.g. {"dm_started": 89, "lead_created": 88}) on GET /api/v1/campaigns at every level, GET /api/v1/ads/{id} and the GET /api/v1/campaigns/unattributed remainder. A lead-gen or conversational account no longer reads a campaign row of zeros.
  • Added · #1824 — Each campaign conversion row now carries outcome_event_id — the id Atribu returns at ingest — so a caller that stored it can join the drill back to its own record. conversion_id is a different id. Nullable.

2026-09-29

  • Added · #1800 — Provider outcomes for partner apps. New webhook event outcome.recorded (provider atribu, opt-in like every lifecycle event): each GoHighLevel and MercadoPago outcome recorded on a profile your app provisioned, with a stable id, data.provider, outcome_type, occurred_at, customer_key, value, and contact only when your app's grant carries customers:read. Never emitted for a healthcare-family profile. New GET /api/v1/outcomes/recorded (conversions:read or customers:read) replays the same events in the same order from an opaque cursor (90-day window).
  • Changed · #1776 — POST /api/v1/adsets/{id}/budget refuses an ad set that is ARCHIVED or DELETED on Meta, or whose campaign is, with the new 409 adset_not_editable (status read live, Atribu's synced status only as a fallback). Nothing is written to Meta; the refused attempt is audited.

2026-09-28

  • Breaking · #1800 — CRM stage mapping for partner keys. New GET /api/v1/connections/{provider}/stages (gohighlevel; connections:read or analytics:read; ?locale=es|en): every pipeline stage with the suggested outcome, the mapping in force (current.source: suggested, user or engine_default), whether it can be written and, at the top, status, can_write and next_step. Write a stage with PATCH /api/v1/outcome-definitions/{id} {event_key} (goals:write, entitlement-gated, idempotent). Breaking (narrow): on a pipeline_stage definition, event_key must now be one of the read's outcomes[].key on both that PATCH and PUT /api/v1/profile/outcome-definitions; anything else is 422 validation_error.
  • Changed · #1800 — CRM stages that are not outcomes stop producing outcome rows. A new GoHighLevel stage with no suggestion is stored as unmapped (it used to become lead_created silently). An opportunity in a stage mapped to unmapped or ignored (including existing stages mapped to ignored) records no outcome, so no conversion and no attribution, until the stage is mapped. Won / lost / abandoned deals are still recorded whatever their stage. No response shape changes; the visible effect is the customer journey timeline. Earlier rows are kept.
  • Added · #1800 — Reporting currency at provisioning. POST /api/v1/profiles and POST /api/v1/workspaces accept reporting_currency (ISO-4217) for a new workspace; on POST /api/v1/profiles it defaults to the request's currency. With neither, the workspace takes the currency of the first ad account connected to it instead of a silent USD. Applied only when the call creates the workspace; change it later with PATCH /api/v1/workspaces/{workspaceId}/reporting-currency, which an ad-account connect never overrides.
  • Added · #1030 — POST /api/v1/partner/ad-referrals: record the Meta ad a PAST click-to-message conversation came from, for a business moving to Atribu from another WhatsApp bot. Each referral becomes a touch at occurred_at, so later money (closed_won, payment_received) is credited to the ad, only inside the profile's attribution windows. It creates no lead and never changes one. Nothing from the call is sent to Meta: the click id stays on the touch and is not stored on the customer. The ad must be one of the profile's own Meta ads (422 unknown_ad). Scoping and customer rules as POST /api/v1/partner/declared-source; idempotent on idempotency_key. POST /api/v1/partner/ad-referrals/preview reports, read-only, how many existing conversions a batch of up to 500 would re-credit, per model.

2026-09-27

  • Changed · #1800 — Connect hand-off hardening. One consent per hand-off (a double-click writes once, and the other tab normally reports the winner's outcome; confirm with the poll); a consent that stops partway on our side frees the link again after 3 minutes; a completed hand-off reports its real outcome on a second click; new reason internal_error (not settled, retry the same link). POST /api/v1/connections/pending/{provider}/finalize answers 409 account_connected_elsewhere (new error code) for an account live on another profile of the workspace, keeping the parked token.

2026-09-26

  • Breaking · #1800 — Partner connect hand-off. A connect hand-off now carries start_url, which goes straight to the provider's consent screen, and every ending returns your user to your return_url with status (success, pending_selection, failed, cancelled), a machine reason and handoff_id. The same reason lands on result.reason. The /h/ page shows your app's name and logo. Breaking: a return_url your app is not registered for is now a 400 at the mint (it used to fail the hand-off later); a user who cancels on the provider's screen settles the hand-off cancelled with user_cancelled (was failed with provider_denied); a hand-off's bounce says failed/cancelled where it said error; and handoff_unusable is now handoff_expired or handoff_used.

2026-09-25

  • Changed · #1799 — POST /api/v1/partner/declared-source: customer accepts { external_ref, email?, phone? }. An unknown external_ref sent with an email and/or phone creates the customer from that PII and binds the ref (exactly as POST /api/v1/events does for user_traits.external_id), so answers can be backfilled for people Atribu has never seen; a ref alone still never creates one (422 unknown_customer_ref). wa_id is now accepted on healthcare-family profiles. A national id (RUT) is refused with 400 invalid_parameter.
  • Added · #1782 — privacy_mode (standard | platform_safe | hipaa) on POST /api/v1/profiles and on PUT /api/v1/profiles/{profileId}/entitlements/atribu_attribution. It is written before the grant widens, so a healthcare profile's first attribution key is minted without customers:read and visitors:read. Omitting it changes nothing.
  • Changed · #1782 — Policy S5 now covers keys that already exist. When a profile becomes healthcare-family (privacy_mode platform_safe / hipaa, or the workspace's is_healthcare_agency), its live delegated keys carrying customers:read or visitors:read are revoked. Mint a replacement with grant_type=client_credentials; it comes back without both scopes.
  • Added · #1776 — Campaign pause and resume. POST /api/v1/campaigns/{id}/pause and /resume change one campaign's status by its Meta id in ONE write — never a per-ad fan-out — with affected_ads_count, the ledger action_id and a ready undo call in the answer (campaigns:apply, Idempotency-Key required, audited). Both are idempotent on the end state (outcome: "no_change"). A resume after an Atribu pause is that pause's undo (rollback_of); ads paused on their own stay paused. New codes campaign_not_found, campaign_not_pausable, campaign_not_resumable. GET /api/v1/campaigns/{id}/status returns what a consent screen needs first — live status, the ads a pause would stop, daily budget, recent daily spend, each action's availability with a reason, and a confirm_token to derive your Idempotency-Key from. Shared class codes can carry error.reason.
  • Breaking · #1771 — Link tokens by your own customer key. POST /api/v1/customers/link-tokens accepts customer_key (the user_traits.external_id you ingest) as a selector, and every result row now carries customer_key; a row that did not mint also carries a reason (no_match, erased, no_selector, unknown_identifier_type, malformed_selector) and a message. New partner_page recipe, and every recipe gains requires. Breaking for a key without customers:read: its rows no longer include customer_profile_id. identifier_type is now one of five values (an unknown type makes the row invalid instead of not_found), and a deployment without a token secret answers 503 link_tokens_disabled instead of service_unavailable.
  • Added · #1770 — Partner erasure by your own key. POST /api/v1/customers/erasures (attribution:write) erases one customer of the key's profile named by customer_key, email, phone or a pinned identifier, and answers a receipt: erasure_id, request_ref, status, customer_key, reason, requested_at, completed_at. Idempotent on request_ref (a replay answers 200 with the original receipt; the same ref for another customer is 400). API keys only (a session gets 403 api_key_required); never needs the attribution add-on. An unknown customer is 404 customer_not_found, an unknown receipt 404 erasure_not_found. Conversions stay in reports without a person; the key stops resolving and link tokens stop binding. GET /api/v1/customers/erasures/{erasure_id} reads the receipt. It never carries customer_profile_id.
  • Changed · #1768 — By-key journey misses get their own codes. GET /api/v1/customers/journey?customer_key= answers 404 customer_not_found (unknown, erased or another profile's customer, all the same) and GET /api/v1/conversions/{id}/journey answers 404 conversion_not_found, instead of not_found, which also means a route the deployment does not serve. Same status; branch on the code.
  • Added · #1708 — POST /api/v1/partner/declared-source: a partner that asks "how did you find us?" in its own WhatsApp conversation sends the interpreted answer, stored beside click attribution with collected_via = partner. Name the customer by exactly one of external_ref (your own customer id, resolved read-only — required on healthcare-family profiles) or wa_id. Idempotent on wamid, one answer per customer, first wins; an unknown answer_key is 422 unknown_answer_key, an unknown external_ref is 422 unknown_customer_ref. The declared-source vocabulary gains walk_in (walked or drove past the place).

2026-09-24

  • Added · #1766 — Journey reads for keys without customers:read. GET /api/v1/conversions/{id}/journey now opens at conversions:read: a key without customers:read gets the key-only projection (touch, channel, campaign / ad set / ad by platform id, click-id kind, join method, credit, in_window) with meta.conversion.customer_key — your own customer id — and no person, page, device, geo or session fields at all; a key with it gets the same response as before. New: GET /api/v1/customers/journey?customer_key= returns one person's whole path by the external_id you ingest, with meta.person (counts and the newest 50 conversions); ?email= / ?phone= need customers:read. Healthcare-family profiles' keys now also leave out visitors:read (policy S5).
  • Added · #1696 — Apply Atribu's URL parameters to Meta ads. POST /api/v1/quality/ad-ids/url-tags/apply writes them onto up to 25 of the ads GET /api/v1/quality/ad-ids lists, merged with each ad's own, and returns one result per ad (applied, skipped with a reason, or Meta's refusal). Every call must carry acknowledge_learning_reset: true: the change replaces the ad's creative (same post), which Meta treats as a significant edit. …/undo puts one ad back on its previous creative; GET …/url-tags says whether the profile can apply at all (ads_management + campaigns:apply) and lists recent changes.
  • Added · #1676 — Partner ad operations. POST /api/v1/ads/{id}/pause and /resume pause or resume one ad by its Meta id with no recommendation behind it, and POST /api/v1/adsets/{id}/budget changes one ad set's daily budget (±50 % of the live budget per call, 422 budget_change_out_of_bounds outside it) — all campaigns:apply, Idempotency-Key required, audited and reversible. A connection whose Meta grant lacks ads_management answers 403 meta_write_permission_missing before anything is written. GET /api/v1/conversions/{id}/journey returns the visitor timeline behind one conversion, addressed by its outcome_event_id, conversions.id or your own event key.
  • Changed · #1676 — On a key minted by the app that provisioned the profile, applied_by may be omitted: the apply is attributed to the workspace's owner (the user provisioning created) while it is still the active owner. An explicit applied_by, and every other key, behave as before.

2026-09-23

  • Added · #1670 — POST /api/v1/events accepts an optional action_source in Meta's vocabulary (website, business_messaging, physical_store, system_generated, app, phone_call, chat, email, other). It reaches Meta verbatim on every Conversions API export of the event, in every privacy mode, and any value other than website ships with no event_source_url — even when the buyer has fbc/fbp browser signals. A business_messaging event with click_ids.ctwa_clid goes to the dataset linked to the profile's WhatsApp Business Account; without that dataset or a click id it is sent as chat. Omitting the field keeps the previous inference.
  • Added · #1670 — Each deliveries ledger entry now carries action_source: the Meta action_source the stored payload carried (the declared value when there was one, else Atribu's inference). null for a non-Meta delivery or a row skipped before a payload was built.
  • Changed · #1669 — Platform-Safe now sends to the profile's EXISTING Meta dataset by default. A new platform_safe_target (existing | clean, read on the catalog settings and on GET /api/v1/exports/destinations) decides which dataset gets the scrubbed feed: under existing the ordinary Meta CAPI destinations ship and nothing is recreated; under clean only the Clean Dataset ships, as before. Profiles that already had a Clean Dataset were moved to clean, so their routing is unchanged. A successful provision sets the target to clean, and its checks report it. Deliveries held for a leftover Clean Dataset under existing are skipped with platform_safe_clean_dataset_inactive.
  • Added · #1669 — POST /api/v1/conversion-sync/platform-safe/target switches a Platform-Safe profile between its existing dataset and a Clean Dataset. clean provisions the Clean Dataset and lists the ad sets to recreate (the target flips only when a Clean Dataset exists); going back to existing answers 409 clean_dataset_has_live_ad_sets while Clean Dataset ad sets still deliver, unless confirm_live_ad_sets: true. The wiring read's platform_safe block gains target, target_destination_ids, event_mapping (the event name each rule sends, with a 7-day double-count check against the dataset's own counts), categorization (the Events Manager checklist and the open platform_safe_events_not_accepted alert) and the switch_to_clean_dataset remediation; under existing it has no provision or recreate step. Clean Dataset ad sets now optimize on the dataset's standard event (optimize_on: standard_event), not on a custom conversion that Core Setup would silence.
  • Added · #1668 — A partner app's DPA is accepted once, at the app. An owner or admin of the workspace that administers the app accepts it in the console (Workspace settings → Compliance), and every profile the app provisioned — and every one it provisions later — reads dpa_status: accepted with that version and acceptor, so sign_dpa no longer appears for them. Withdrawing returns those inherited profiles to withdrawn; a profile its own owner accepted keeps its acceptance. An API key or MCP token still cannot accept.
  • Fixed · #1653 — An email send the mailbox's provider refuses is no longer a 502. A refusal about the mailbox answers its own code — 403 account_suspended, 403 mailbox_disabled, 403 auth_revoked (with reconnect_required: true) or 429 quota_exceeded — a refused recipient answers 422 recipient_rejected, and every one carries the provider's own words in provider, provider_status, provider_code and provider_message. The connection reports status: "needs_action" (or reconnect_required for a dead grant) with the provider's message in status_reason, and a channel.health.updated event is pushed on provider email; the first send the provider accepts clears it. Only a transient provider failure still answers 502/503.
  • Fixed · #1653 — Inbound email delivery_failure now carries the report's status on Gmail bounces — it was always null there, because Gmail nests the per-recipient fields one part deeper than Atribu read, so a permanent 5.x.x looked like a soft bounce. It also gains action (failed, delayed, delivered, relayed or expanded) and diagnostic_code (the remote server's own words), both null when the report has none.
  • Fixed · #1653 — An email send from an Outlook mailbox now answers a real provider_message_id — the Microsoft Graph immutable id of the sent message (its copy in Sent Items) — instead of "", for a new thread and for a reply alike. thread_id and rfc822_message_id are unchanged. Inbound Outlook events keep Graph's default-format id, so recognise your own send by rfc822_message_id, not by comparing ids.
  • Changed · #306 — The automated duplicate-and-swap is open to partners behind a consent contract. An API key or MCP user token that sends confirm_recreate: true or confirm_duplicate_swap: true must also send consent: { version, text_hash, accepted_by, accepted_at } for the current consent text (2026-09-23.duplicate-swap.v1, published on the Partners page); without it the call is 409 consent_required and nothing reaches Meta. Signed-in sessions are unchanged. Both responses gain warnings (new ad set ids, learning reset, double spend), and error envelopes may carry consent.
  • Changed · #306 — Platform-Safe provisioning is ready for partners. An API key or MCP user token must switch the profile to platform_safe first, or gets 409 platform_safe_required; no usable Meta Ads connection is 409 meta_connection_required. A Clean Dataset that already covers every tracked outcome answers status: "already_provisioned" with no Meta call, and every 200 now carries checks (privacy mode, Meta connection, ads_management, mapped outcome types). With no Meta CAPI destination, the profile's single Meta Ads connection is used.
  • Added · #306 — The wiring read has a platform_safe block for Platform-Safe profiles: platform_safe_dataset: provisioned | missing (remediation provision_platform_safe_dataset), only the Clean Dataset's rows in results, recreate_ad_set instead of wire_optimization, and, on a live check, the ad sets to recreate with their target conversion and Ads Manager steps. It is null in every other mode.

2026-09-22

  • Added · #1653 — An email send can now START a thread, and the response says which thread it started. POST /api/v1/messages with channel: "email" answers with thread_id (Gmail threadId / Outlook conversationId — the same value the inbound message.received event carries, so the thread you open is the thread the reply arrives on) and rfc822_message_id (the Message-ID of what was sent). Send to + a subject with no thread_id and a NEW thread is opened; a reply returns the thread it replied into. The email content also takes an optional list_unsubscribe: { url, one_click }, which writes List-Unsubscribe: <url> and — with one_click: true, which requires an https:// url — List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058). And the inbound email event gains delivery_failure: null for an ordinary message, or { failed_recipient, status, original_rfc822_message_id } parsed out of the RFC 3464 delivery-status report a bounce carries, where original_rfc822_message_id equals the rfc822_message_id your send returned. Everything here is optional and additive — an existing consumer's request and response are unchanged.

2026-09-15

  • Added · #1517 — The three message-send routes now honour an optional Idempotency-Key header, so a send lost to a timeout or a 5xx can be retried without delivering twice. A repeat within 24 hours replays the first response verbatim and sends nothing; scope is your API key + the route + the key value. A repeat that arrives while the first send is still in flight answers 409 idempotency_key_in_flight (a new error code — retry the same key with backoff) rather than risking a second delivery, and a repeat carrying a different body answers 409 idempotency_key_conflict. Only a 2xx is stored, so a send that genuinely failed can be retried under the same key. Omit the header and nothing changes.

2026-09-14

  • Added · #1420 — GET /api/v1/partner/whatsapp/ads lists a profile's click-to-WhatsApp ads with their destination (type, WhatsApp number, Page), call-to-action type, greeting and greeting_status, paginated by keyset on ad_id. Served from the nightly Meta sync, which now reads that configuration for every CTWA ad — so /partner/whatsapp/ads/{ad_id}/welcome-message answers for a synced ad without a Graph call, and still falls back to a live read for one launched since the last sync. No spend, no metrics, no PII.

2026-09-11

  • Added · #1402 — GET /api/v1/partner/whatsapp/ads/{ad_id}/welcome-message reads the welcome message ("Saludo automático") a click-to-WhatsApp ad shows a customer BEFORE their first message — the greeting, the pre-filled text and the ice-breaker buttons. Meta never delivers it over the webhook. welcome: null means the ad has none configured; 404 means Meta does not show us the ad, 409 that the profile has no usable Meta Ads connection.

2026-09-07

  • Added · #452 — Per-consumer usage: request counts, the 4xx/5xx split and p50/p95 latency, per API key for a workspace and per registered app for Atribu ops. Every rate-limited response now also carries X-RateLimit-Reset.
  • Changed · #452 — A registered consumer app now has a rate TIER (standard 300/min, elevated 1200/min, internal 6000/min) that sets its per-minute allowance on client-credential requests. An atb_live_ key keeps its own rate_limit_per_minute — the two meter different principals and are not merged.

2026-09-06

  • Added · #1209 — GET /api/v1/openapi.json is served from the API host itself, so the spec and the API it describes cannot come from different deploys.
  • Added · #1190 — DELETE /api/v1/workspaces/{workspaceId} archives a workspace, driven by membership.
  • Changed · #1185 — An OAuth app's empty allowed_return_origins no longer means "no bounce": the effective allowlist derives from its own redirect_uris. return_url_supported on GET /api/v1/me's app-credential branch reports the effective set, and the admin routes return effective_return_origins.

2026-09-05

  • Breaking · #1159 — Two new scopes, workspaces:write and profiles:write, both granted by mcp:write. An MCP user token minted with the default mcp:read grant could create workspaces and profiles; it now gets 403 insufficient_scope and nothing is created on the way to that refusal. Sessions and app credentials are unchanged.
  • Breaking · #1154 — A webhook subscription URL must point at a public host; a private or non-routable target is refused at registration AND again at delivery time, and redirects are no longer followed.
  • Added · #1109 — An agent can start a provider connect and hand the human a session-less URL. Stripe and MercadoPago join Meta Ads, Google Ads, Google Search Console and GoHighLevel on the shared prepareConnectStart path.
  • Added · #1099 — Every operation in the published spec carries structured x-atribu-scopes, x-atribu-auth, x-atribu-idempotency and x-atribu-scope-grain extensions, plus a response example per tag.
  • Added · #1089 — Every error envelope now carries docs_url, pointing at that exact code's section of the generated errors reference.
  • Deprecated · #1086 — POST /api/v1/goals is deprecated in favour of POST /api/v1/goals/definitions, which can express what the older collection cannot (updates, an explicit conversion_key, lookback_window_days, is_default).

2026-09-03

  • Added · #1000 — GET /api/v1/workspaces/{workspaceId}/pii-access-log — who read this workspace's customer/visitor personal data, when, through which route and with what result. Durable for 365 days.
  • Changed · #502 — DELETE /api/v1/connections/{id} now also revokes key generations older than the authorization that names them, when the authorization revoked is the app's last live one on that profile. revoked_keys can exceed the number of keys you minted under it.

2026-09-02

  • Added · #533 — POST /api/v1/ads/{id}/creative-analysis queues an on-demand creative analysis at the new creatives:write scope. It never runs a model on the request; it answers 202 and the GET on the same path is the poll.
  • Added · #391 — Two commerce reads at the new commerce:read scope: a connected store's catalogue (an ascending change feed you walk with updated_since) and an order lookup by number, email or phone. The order row carries no customer identity.

2026-09-01

  • Breaking · #898 — GET /api/v1/overview answers spend: null and roas: null — not "0" — when spend is unmeasurable for the requested filters. A measured zero is still "0". Guard the denominator; do not ?? 0 it.
  • Added · #675 — Eight classification writes at goals:write. Every one of them changes the PAST: each queues a full-profile replay, so read replay_queued on the response — false means the change is saved but existing sessions keep their old channels until a replay runs.

On this page