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
POST /api/v1/dashboard/chat2026-10-102027-01-08—
POST /api/v1/workspaces/{workspaceId}/top-performers/chat2026-10-102027-01-08—

Changes

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

2026-10-15

  • Changed · #2106 — The order.status.changed webhook (provider shopify) fires for every order, not only shipped ones: status is the order's current milestone (created, paid, fulfilled, delivered, refunded, cancelled), one event per (order, status). data adds what a subscriber needs to match the order: attributes (cart / note attributes, so a cart permalink's attributes[ref] comes back), lines[] (variant_id, quantity, sku, price, …), financial_status, fulfillment_status, name, total_price, currency, customer_id, cart_token, checkout_token, timestamps, and the buyer's email / phone with fallbacks (null when Shopify withholds them). A consumer that only handles fulfilled / delivered should ignore the other statuses.
  • Added · #2105 — A live Shopify read passthrough at the new commerce:live scope: a connected store's catalogue (every variant, current stock) and its orders updated since a time (buyer email and phone, cart attributes, every line), read from Shopify in the request and never from Atribu's copy. Keyset-paginated, throttle-tolerant. The new commerce connect scope mints commerce:read + commerce:live.
  • Added · #2104 — The connect hand-off accepts shopify, with the store in shop (<handle>.myshopify.com, the bare handle or an admin URL). The merchant installs Atribu's Shopify app — or the custom-distribution app registered for that store — and returns to your return_url with no Atribu login and no plan page. The connection's lifecycle reaches your atribu subscription as connection.connected, connection.revoked (on uninstall) and connection.reconnect_required, with data.provider: "shopify".
  • Changed · #2101 — kpis.repeat_rate_pct, kpis.median_days_to_second, repeat_purchase_histogram and the months after the first in cohort_retention now count a REPEAT payment the way GET /api/v1/overview does: a payment that is not part of the customer's first purchase (is_first_payment = false — a balance charged within 7 days of the deposit, or another order of the same first purchase, is not a repeat). On one profile the repeat rate read 67 % where 1 of 6 new customers had actually paid again.
  • Fixed · #2101 — credited_revenue on a touch is cash only: it no longer adds the estimated value of a credited appointment or lead (one customer showed USD 4,300 credited to a touch after paying USD 800).
  • Added · #2101 — Each row also returns meta_reported_conversions (Meta's whole conversion figure for the entity) and meta_reported_chats (its chats started), so a client can account for every part of Meta's number.
  • Changed · #2098 — business_conversions is now the canonical conversion count — the one GET /api/v1/overview returns as current.outcomes and Atribu's own screens print — over the last days calendar days ending today in the profile's time zone. It used to count every conversions row in the tracker's rolling window, including suppressed conversions (e.g. GoHighLevel SaaS recharges classified as not sold by ads), installments and refunds/disputes; on one profile it read 111 where the app read 86.
  • Fixed · #2098 — signals.checkout_metadata.cash_events (and the split under it) counts the payments GET /api/v1/overview counts as cash_payments: a payment whose conversion is suppressed (e.g. a recharge not sold by ads) or an installment is no longer in it.
  • Fixed · #2098 — /attribution/models: a campaign row's conversion_count (and its lead / appointment / payment counts) counts a conversion only where the model gives it credit. A conversion was also counted in every other row where it had a zero-credit touch, so under a single-touch model (last_touch, first_touch, last_non_direct) the rows (and totals) added up to more than GET /api/v1/overview; they now add up to it exactly. Under a multi-touch model a conversion split between rows still counts in each, and credited_conversions is the share that adds up.

2026-10-14

  • Added · #2075 — Meta analytics for one WhatsApp template: GET /api/v1/whatsapp/templates/{name}/analytics?connection_id&language&start&end answers Meta's own sent / delivered / read, clicks per button (quick-reply and URL buttons) and cost over UTC days, as totals and a daily series (range ≤ 90 days, cached 15 minutes). The first call on a WABA with template insights off switches them on and answers 409 insights_enabling with retry_after_seconds (422 insights_unsupported on a WhatsApp Business app / coexistence account, where Meta refuses that switch); 404 template_not_found, 429 on a Meta throttle, 502 meta_unavailable. Every error envelope with a Retry-After header now also carries retry_after_seconds.

2026-10-11

  • Added · #4145 — New webhook event leadgen.received (provider atribu, opt-in): a Meta Instant Form submission on a Page the profile connected. data is the Graph lead merged over Meta's webhook value (leadgen_id, form_id, ad_id, campaign_id, field_data, …) plus profile_id; the event id is the leadgen_id. Same delivery as every partner event (HMAC, 24 h retry ladder).
  • Added · #2441 — POST /api/v1/campaigns/{id}/budget raises or lowers one campaign's own (Advantage) budget, daily or lifetime, in one audited write (campaigns:apply, a person on the record, ±50 % of the live budget per call, undo through the conversion-sync rollback). New code campaign_not_editable (409). POST /api/v1/adsets/{id}/budget also accepts new_lifetime_budget_minor and answers budget_type / previous_budget_minor / budget_minor.

2026-10-10

  • Deprecated · #1764 — The in-app chat routes are retired. POST /api/v1/dashboard/chat and POST /api/v1/workspaces/{workspaceId}/top-performers/chat answer 410 feature_retired with Deprecation / Sunset headers until 2027-01-08, then 404. Ask the same questions from your own Claude, Claude Code or any MCP client connected to https://mcp.atribu.app: its tools are these same public reads.
  • Added · #1764 — GET /api/v1/workspaces/{workspaceId}/reports/hub carries capabilities — whether this caller may use the hub's bulk schedule (can_edit) and bulk generate (can_apply), at the workspace grain those writes are checked at.
  • Added · #1764 — GET /api/v1/conversion-sync/data-source-options lists the profile's connectable Meta data sources (WhatsApp, Messenger, Instagram, website) and POST /api/v1/conversion-sync/datasets creates or retrieves the dataset for one of them and wires it as a Conversions API destination — the two steps of the console's «Connect a data source» wizard, now published.

2026-10-09

  • Breaking · #1761 — Learned values are no longer bare numbers. top_performer_likelihood, score.top_performer_likelihood_calibrated, hazard_30d_pause, hazard_30d_degradation, fatigue_risk_tier, expected_lifespan_days and risk_rank_score are { value, status: served | withheld | not_applicable, reason }; read value only when status is served. A model serves only after an out-of-time evaluation whose 95% CI clears chance — none does today, so these are withheld. /ads/fatigue-risk returns model.status = withheld and an empty ads list. Rule verdicts gain rule: { id, thresholds, inputs } (composite_rule, fatigue_state_rule, scaling guidance.rule).

2026-10-08

  • Added · #1886 — POST …/data-sources/instagram/ensure refuses an Instagram account connected through Instagram Login with 409 instagram_facebook_login_required, before any Meta call: Meta creates Instagram datasets only for an account connected through Facebook Login for Business. GET …/data-sources reports it first as the instagram entry's reason_code.

2026-10-07

  • Added · #1886 — Instagram business-messaging lane: POST /api/v1/conversion-sync/data-sources/instagram/ensure creates the dataset linked to the profile's Instagram professional account (instagram_connection_required 409 without one), and the data-sources status reports it. An event whose click_ids carry messaging_channel: instagram + ig_sid + instagram_business_account_id is exported to that dataset as business_messaging (Meta's Instagram user_data); with no Instagram dataset it goes to the website pixel as system_generated with those fields dropped. Every other event is exported as before.
  • Changed · #1885 — outcome.recorded now delivers a healthcare-family profile's events to the app that provisioned the profile, and only to it — the webhook and GET /api/v1/outcomes/recorded with that app's key; every other caller still gets nothing (403 healthcare_scope_forbidden on the replay). That app gets the person when its grant on the profile opens contact, the webhook's usual rule, on both surfaces.
  • Added · #1885 — outcome.recorded events (webhook and replay) carry data.outcome_event_id and data.conversion_id — the ids GET /api/v1/campaigns/{id}/conversions returns — so an event joins to the campaign credited for it. conversion_id is null while the outcome has produced no conversion.

2026-10-06

  • Added · #1880 — WhatsApp templates with a media header: header_media: { format: IMAGE | VIDEO | DOCUMENT, url } on the template create, mutually exclusive with header_text. Atribu downloads the public https URL (JPEG/PNG ≤ 5 MB, MP4 ≤ 16 MB, PDF ≤ 100 MB) and uploads it to Meta as the review sample; a wrong type or unreachable URL is 422 invalid_content, an oversized one 413 payload_too_large. Each send carries its own media in a header component ({ type: 'image', image: { link } }), forwarded to Meta unchanged.

2026-10-05

  • Added · #1876 — platform_event_name_overrides.meta_capi_messaging on export rules: the event name for click-to-WhatsApp (business_messaging) sends, one of Meta's messaging names (e.g. LeadSubmitted, QualifiedLead, Purchase), or null to keep the rule off that lane; anything else is a 422. Absent, Atribu maps by source event (appointment_booked → QualifiedLead). meta_capi now names the website event only. The WhatsApp test send uses LeadSubmitted.

2026-10-04

  • Fixed · #1869 — Creating an export destination, or changing one's pixel / conversion action / connection, so that it matches another destination of the profile now answers 409 destination_conflict (new error code) with conflicting_destination_id, instead of a 500. Nothing is written.
  • Added · #1863 — POST /api/v1/events/retractions (events:write): retract an event you ingested through POST /api/v1/events, by the idempotency_key it was sent with. One transaction deletes the event, its conversions and their attribution, recomputes the customer's first-payment flags, queues the attribution recompute and records an audit entry naming your key and reason; the answer is counts only. Idempotent (status: "already_retracted", zero counts). Refused, deleting nothing, with 409 event_already_exported once a conversion export exists, and 409 event_linked_to_conversation when a conversation recorded the event as its lead; an unknown key is 404 event_not_found. Never needs the attribution add-on.
  • Changed · #1838 — Customer-list consent v2 (2026-10-04.customer-list.v2) is now the current version and is required to create a list. A v2 list uploads hashed e-mail, phone, first name, last name and country. A list created under v1 keeps uploading hashed e-mail and phone only, and re-POSTing its v1 acceptance stays idempotent. A POST with v2 on a live list re-records the consent.

2026-10-03

  • Fixed · #1859 — PUT /api/v1/profiles/{profileId}/audience-exclusions no longer fails with 500 on batches of a few hundred rows. If Atribu's database cannot be reached mid-request it now answers 503 database_unavailable with Retry-After, never 500. Retry the same body.

2026-10-01

  • Added · #1841 — GET /api/v1/attribution/ad-people: distinct people with an ad-tagged Touch per month, day or ad (profile time zone), split by origin (a partner's historical import, a conversation's ad referral, a website visit, other), beside Meta's own reported messaging conversations started. Counts only, analytics:read.
  • Added · #1841 — messaging_conversations_started_7d on each GET /api/v1/ads/trend row: Meta's own reported messaging conversations started per entity and day.
  • Added · #1838 — A Meta customer list of a Platform-Safe profile's paying customers (hashed e-mail and phone only) and its 1 % lookalike: GET|POST|DELETE /api/v1/partner/audiences/customer-list and POST …/lookalike. Off for every profile until Atribu enables it. Partner apps keep customers out of every audience with PUT /api/v1/profiles/{profileId}/audience-exclusions.
  • Fixed · #1838 — On a Platform-Safe profile, proposing or confirming a Conversion Sync audience now answers 409 platform_safe_audiences_disabled before any Meta call. It could create empty audiences in Meta before.
  • Added · #1140 — GET /api/v1/conversion-sync/data-sources reports, per data source (WhatsApp, website pixel), whether the Meta dataset is wired, what blocks it and a 30-day preview of what it would export. POST …/data-sources/whatsapp/ensure creates the WhatsApp Business Account's dataset (201, or 200 when it already exists); …/website/ensure attaches one of the ad account's EXISTING pixels (selectable_pixels) and never creates one. For an app-provisioned profile it is the only way that dataset gets created: connecting now only reports ready. Each data source reports shared_with_profiles / warning: meta_destination_shared when another profile already sends the same ad account or dataset to Meta. New error code whatsapp_connection_required (409).
  • Changed · #1140 — A conversion declared business_messaging (or chat) without a click-to-WhatsApp click id is no longer exported as chat: it goes to the website pixel as system_generated (filter action_source_degraded:business_messaging_to_system_generated). No event without a ctwa_clid is ever sent with a messaging action_source. When the profile has no website pixel, such an event is not sent and the ledger now records it as skipped with skip_reason: no_website_destination instead of dropping it silently.

2026-09-30

  • Added · #1836 — 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