{"openapi":"3.1.0","info":{"title":"Atribu API","version":"1.0.0","description":"Marketing attribution API. Get campaign ROAS, visitor analytics, customer journeys, and revenue attribution data.\n\n## Authentication\n\nAll requests require a Bearer token in the `Authorization` header:\n```\nAuthorization: Bearer atb_live_your_key_here\n```\n\nGet your API key from **Settings > API Keys** in the Atribu dashboard.\n\nTwo more credentials ride the same header, both identifying a PERSON rather than a tenant, and both requiring `?profile_id=<uuid>` on profile-scoped routes:\n\n- an **MCP user token** (`atb_user_…`) — a public integration path, and the credential an AI agent holds after the MCP OAuth flow at `https://mcp.atribu.app` (or after `POST /api/v1/me/mcp-tokens`). One token covers both the MCP server and this API. See the `userToken` security scheme.\n- a **Supabase session access token** — Atribu's own dashboard, 120 requests/minute per user. See the `bearerAuth` security scheme.\n\n## Scopes\n\nAPI keys have granular scopes:\n- `analytics:read` — Read aggregated analytics: overview, timeseries, breakdowns, quality, touches, and channel/referrer/page/keyword/country/device/browser/OS dimensions.\n- `campaigns:read` — Read campaign, ad set and ad performance: ROAS, trends, archetypes, creative patterns, DM ads, forecast outlook and top performers.\n- `conversions:read` — Read conversion counts and revenue, including cash-only revenue and conversion timeseries.\n- `commerce:read` — Read the merchant's product catalogue (products, variants, stock and prices) and look an order up by number, email or phone. Returns no customer identity.\n- `customers:read` — Read the customer list and per-customer journey detail — personally identifiable information.\n- `visitors:read` — Read the visitor list — personally identifiable information.\n- `realtime:read` — Read the live visitor count.\n- `exports:read` — Read the conversion-export/signal-sync surface: Meta CAPI wiring diagnostics, dataset quality, Custom Audiences, export deliveries, and the compliance state behind Platform-Safe Mode.\n- `exports:write` — Configure the conversion-export pipeline: create, edit and disable export destinations (Meta dataset/pixel, Google Ads conversion action) and the signal rules that map a conversion definition to a platform event, and send a Meta test event.\n- `events:write` — Ingest server-side outcome events (e.g. a closed sale) via POST /api/v1/events.\n- `goals:write` — Create, list and edit conversion goal definitions.\n- `attribution:write` — Trigger an attribution recompute for a profile.\n- `tracking:write` — Configure a site's tracking installation: issue and revoke tracking keys, change the tracker's session/enrichment settings (which enqueues a full attribution replay), and start an install verification.\n- `reports:write` — Create, schedule, generate and send attribution reports (report schedules, deliveries and send).\n- `workspaces:write` — Create a workspace (POST /api/v1/workspaces). Not granted by any OAuth connect scope; an MCP user token holds it through the `mcp:write` grant.\n- `profiles:write` — Create a profile inside a workspace the caller already belongs to (POST /api/v1/profiles). Not granted by any OAuth connect scope; an MCP user token holds it through the `mcp:write` grant.\n- `campaigns:apply` — Apply or dismiss a campaign recommendation. Not granted by default — a workspace admin must opt a key into it.\n- `creatives:write` — Queue an on-demand AI analysis of one ad's creative (`POST /api/v1/ads/{id}/creative-analysis`). Each accepted call queues a model call against the workspace's daily creative-analysis allowance, so it is capped per profile and per key per day and is not granted by default — a workspace admin must opt a key into it.\n- `calendar` — Create, update and delete events on Atribu booking calendars.\n- `calendar.manage` — Create, list and share (ACL) Atribu booking calendars.\n- `calendar.read` — Read-only access to Atribu booking calendars and their events.\n- `whatsapp` — Send and manage WhatsApp messages, templates, flows, broadcasts, calling and phone-number registration.\n- `whatsapp:read` — Read WhatsApp conversations, messages, templates and broadcasts. A read-only subset of `whatsapp`, which also allows sending and managing them.\n- `instagram` — Send and manage Instagram DMs, comment replies, triggers and media.\n- `email` — Read and send connected email (Gmail/Outlook): drafts, threads, labels and search.\n- `conversations:read` — Read the unified inbox: conversation list and message threads across WhatsApp, Instagram and email, plus organic/ad match suggestions, contact-extraction review proposals and the Instagram DM↔payment reconciliation worklist.\n- `conversations:write` — Reply on a conversation, save an email draft, apply Gmail labels, link/unlink a conversation to a customer, and approve/reject a contact-extraction review proposal.\n\nSee the API changelog (docs/api-changelog.md) for scope-catalogue changes — scopes are frozen into a key at mint time, so a key minted before a new scope was added to a grant does not carry it; re-mint via `client_credentials`.\n\n## Rate Limits\n\nDefault: 60 requests/minute. Check `X-RateLimit-Remaining` header.\nHeavy endpoints (customers, visitors, journey) cost 3x.\n\nA few endpoints also carry their own sustained bucket, because one call is far more work than a read. That bucket is separate from the per-minute allowance, so it can neither consume it nor be consumed by it:\n\n| Endpoint | Own bucket |\n|---|---|\n| `POST /api/v1/quality/utm/refresh` | 6 scans/hour per key (each is an 8-12s live scan) |\n| `POST /api/v1/profiles` | 200 provisioning calls/hour per app credential |\n\n## Date Range\n\nMost endpoints require `date_from` and `date_to` in YYYY-MM-DD format. Maximum range: 366 days.","contact":{"name":"Atribu Support","url":"https://www.atribu.app"}},"servers":[{"url":"https://www.atribu.app","description":"Production"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"API key in Bearer format. Get your key from Settings > API Keys in the Atribu dashboard.\n\nTHREE credentials ride this header and the API tells them apart by SHAPE, never by a second header: an `atb_live_` API key (this scheme), an `atb_user_` MCP user token (the `userToken` scheme) and a **Supabase session access token** (three base64url segments).\n\n`api.atribu.app` accepts the session token for Atribu's own console. It differs from a key in three ways: `profile_id` is a REQUIRED query parameter (a user belongs to many profiles — 400 `invalid_request` without it, and 404 `not_found` for one they cannot reach, identical to a profile that does not exist); scopes are derived per request from the user's workspace role and profile permissions rather than frozen at mint time; and the rate limit is 120 requests/minute per user, metered separately from any API key on the same profile.\n\nA session token is a browser credential — it expires in an hour and is refreshed by the Supabase client that minted it. For a program or an agent acting as a person, use the `userToken` scheme instead: same person-shaped tenancy, long-lived, revocable per agent, and obtainable without a browser session of your own.\n\n**Workspace-scoped routes (#487).** A handful of reads answer a question that spans every profile in a workspace (`/workspaces`, `/workspaces/{workspaceId}/…`). Those take the workspace in the path — or `?workspace_id=` where the path does not carry one — INSTEAD of `?profile_id=`, and admit a caller on an ACTIVE workspace membership (400 `invalid_request` without the id; 404 `not_found` for a workspace they are not a member of, identical to one that does not exist). Row scoping inside the workspace still applies: a caller sees only the profiles they can open. **An API key cannot use them** — a key is minted for exactly one profile, so it has no workspace set and no membership list, and receives 403 `insufficient_scope` naming the profile-scoped alternative."},"userToken":{"type":"http","scheme":"bearer","description":"An **MCP user token** (`atb_user_…`) — the credential a person's own AI agent holds, and a fully public integration path (#1084).\n\nHOW TO GET ONE. Either connect over MCP (`https://mcp.atribu.app`) and complete the OAuth 2.1 + PKCE flow, which is the bootstrap for an agent with no Atribu account of its own — the agent registers a client, hands the authorize URL to its human, and holds the token after consent — or, if you are already signed in, mint one with `POST /api/v1/me/mcp-tokens`.\n\nIT IS THE SAME TOKEN ON BOTH SURFACES. One credential covers `mcp.atribu.app` and this API; there is no separate key to mint per profile.\n\nTENANCY IS PERSON-SHAPED, exactly like a session token: `?profile_id=` is REQUIRED on profile-scoped routes (400 `invalid_request` without it; 404 `not_found` for a profile the person cannot reach, identical to one that does not exist), `?workspace_id=` or the path segment on workspace-scoped routes, and the workspace-grain routes an API key is refused on are open to it.\n\nSCOPES ARE AN INTERSECTION, which is the one thing that has no analogue on the other two credentials. The token's `mcp:*` grant maps onto API scopes — `mcp:read` → `analytics:read`, `campaigns:read`, `conversions:read`, `realtime:read`; `mcp:read_pii` → `customers:read`; `mcp:write` → `exports:read`, `exports:write`, `goals:write`, `attribution:write`, `tracking:write`, `reports:write`, `campaigns:apply` — and the request holds the INTERSECTION of that map with what the person's own membership grants. So a 403 `insufficient_scope` can mean either half is too narrow; the message names both. Messaging, calendar, inbox, commerce, `events:write` and `creatives:write` are reachable by no MCP scope. A token minted BEFORE a mapping widens keeps the scopes frozen into its row: re-consent or re-mint to pick the new ones up.\n\nACCOUNT ADMINISTRATION IS OUT OF REACH, deliberately: `/api/v1/me/**` (including `/me/mcp-tokens`) answers 403 to a user token. A credential cannot mint, rotate or revoke the class it belongs to — sign in for that.\n\nRATE LIMIT: the token's own `rate_limit_units_per_minute`, in a bucket of its own, so an agent's loop cannot 429 its owner's dashboard. A revoked or expired token answers 401 with the same envelope a revoked API key gets."},"adminAuth":{"type":"http","scheme":"bearer","description":"Atribu staff secret (`admin_secret_...`) for the `/api/v1/admin/*` surface. Constant-time hashed compare; rotate via `ATRIBU_ADMIN_SECRET` env var on the Next.js service."},"clientSecretBasic":{"type":"http","scheme":"basic","description":"RFC 6749 §2.3.1 `client_secret_basic`. Used on `/oauth/token` and `/oauth/revoke`. The body-form variant (`client_id` + `client_secret` in body) is also accepted per RFC."}},"schemas":{"FunnelStage":{"type":"object","properties":{"step_index":{"type":"number"},"label":{"type":"string"},"step_type":{"type":"string","enum":["outcome_event","page_view"]},"count":{"type":"number"}},"required":["step_index","label","step_type","count"]},"Funnel":{"type":"object","properties":{"funnel_id":{"type":"string","example":"uuid"},"funnel_name":{"type":"string"},"scope":{"type":"string","enum":["all","pipeline_only","contact_only"]},"stages":{"type":"array","items":{"$ref":"#/components/schemas/FunnelStage"}}},"required":["funnel_id","funnel_name","scope","stages"]},"NewVsReturning":{"type":"object","properties":{"new_customers":{"type":"number"},"returning_customers":{"type":"number"}},"required":["new_customers","returning_customers"]},"MessagingAttributionOutcome":{"type":"object","properties":{"conversion_id":{"type":"string","example":"uuid"},"conversion_time":{"type":"string"},"customer_profile_id":{"type":["string","null"],"example":"uuid"},"customer_name":{"type":["string","null"],"description":"Null unless the key also holds the customers:read scope."},"customer_email":{"type":["string","null"],"description":"Null unless the key also holds the customers:read scope."},"value_amount_reporting":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"reporting_currency":{"type":["string","null"],"example":"USD"},"top_candidate_ad_external_id":{"type":"string"},"top_candidate_ad_name":{"type":["string","null"]},"top_attribution_mode":{"type":"string","enum":["exact_dm_ad","exact_follow_proxy","native_conversation_model","native_aggregate_only","organic_or_unknown"]},"top_probability":{"type":"number","description":"0-1."},"top_confidence_band":{"type":"string","example":"high"},"candidate_count":{"type":"number","description":"How many candidate ads were scored for this conversion."}},"required":["conversion_id","conversion_time","customer_profile_id","customer_name","customer_email","value_amount_reporting","reporting_currency","top_candidate_ad_external_id","top_candidate_ad_name","top_attribution_mode","top_probability","top_confidence_band","candidate_count"]},"MessagingConversionCandidate":{"type":"object","properties":{"candidate_ad_external_id":{"type":"string","example":"23851000000012345"},"ad_name":{"type":["string","null"]},"attribution_mode":{"type":"string","enum":["exact_dm_ad","exact_follow_proxy","native_conversation_model","native_aggregate_only","organic_or_unknown"]},"probability":{"type":"number","description":"0-1, the model's credit share for this candidate. MODELLED decision support — never folded into ROAS."},"confidence_band":{"type":"string","example":"high"},"evidence":{"description":"The model's own explanation blob. Opaque by contract: its keys are data, not contract field names, and its shape may change without a version bump."},"model_version":{"type":"string","example":"v1"}},"required":["candidate_ad_external_id","ad_name","attribution_mode","probability","confidence_band","model_version"]},"MessagingConversionChain":{"type":"object","properties":{"conversion_id":{"type":"string","example":"uuid"},"candidates":{"type":"array","items":{"$ref":"#/components/schemas/MessagingConversionCandidate"},"description":"Every candidate ad the messaging attribution model scored for this conversion, highest probability first. EMPTY means the conversion exists and nothing was scored — a conversion that is not this profile's is a 404, never an empty chain."}},"required":["conversion_id","candidates"]},"UtmHealthSummary":{"type":"object","properties":{"scan_window_days":{"type":"number"},"events_scanned":{"type":"number"},"high_count":{"type":"number","description":"High-severity UTM anomalies (placement-in-source, non-channel medium, unsubstituted macros, unresolved paid ad identifiers)."},"medium_count":{"type":"number","description":"Medium-severity anomalies (paid traffic with no UTMs at all)."},"refreshed_at":{"type":["string","null"],"description":"When this cached scan last ran — the daily utm-health-refresh cron, or someone's on-demand scan. Null if nothing has scanned this window yet."}},"required":["scan_window_days","events_scanned","high_count","medium_count","refreshed_at"]},"DanglingAdOffender":{"type":"object","properties":{"ad_external_id":{"type":"string"},"conversions":{"type":"number"},"attributed_cash":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["ad_external_id","conversions","attributed_cash"]},"DanglingAdIdsSummary":{"type":"object","properties":{"window_days":{"type":"number"},"model":{"type":"string","example":"last_touch"},"dangling_conversions":{"type":"number","description":"Cash conversions whose credited touch names an ad id that resolves to no known ad (deleted ad, no account access, or utm_content carrying an ad name instead of its id)."},"attributed_cash":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"reporting_currency":{"type":["string","null"]},"distinct_ad_ids":{"type":"integer"},"top_offenders":{"type":"array","items":{"$ref":"#/components/schemas/DanglingAdOffender"}}},"required":["window_days","model","dangling_conversions","attributed_cash","reporting_currency","distinct_ad_ids","top_offenders"]},"UtmHealth":{"type":"object","properties":{"summary":{"$ref":"#/components/schemas/UtmHealthSummary"},"dangling_ad_ids":{"$ref":"#/components/schemas/DanglingAdIdsSummary"}},"required":["summary","dangling_ad_ids"]},"UtmHealthRefresh":{"type":"object","properties":{"refreshed":{"type":"boolean","description":"Whether a scan actually ran. `false` means the cached summary was still inside the 10-minute idempotency window and is returned unchanged — not that anything failed."},"summary":{"$ref":"#/components/schemas/UtmHealthSummary"}},"required":["refreshed","summary"]},"UtmAnomalySample":{"type":"object","properties":{"value":{"type":"string","example":"instagram_stories","description":"The observed value. `(empty)` when the field was absent."},"count":{"type":"number","description":"How often this value was seen in the window."}},"required":["value","count"]},"UtmAnomaly":{"type":"object","properties":{"severity":{"type":"string","example":"high","description":"How much this issue distorts attribution. Currently `high`, `medium`, `low` or `info`. Treat as an open string: the scan is a database function and can gain a level without a version bump."},"category":{"type":"string","example":"placement_in_source","description":"Which issue this is. Currently `placement_in_source`, `non_channel_medium`, `unsubstituted_macro`, `missing_ad_identifier`, `missing_utms_on_paid` or `inconsistent_source_vocab`. Treat as an open string — the scan can gain a category without a version bump, so branch with a default rather than exhaustively."},"field":{"type":"string","example":"utm_source","description":"The UTM field at fault, or the pseudo-fields `any` / `all_utms` for the categories that span several."},"count":{"type":"number","description":"Events exhibiting the issue — or paid TOUCHES, for `missing_ad_identifier`, which is measured on the touch layer rather than on raw events."},"pct":{"type":"number","example":12.4,"description":"`count` against that category's own denominator (all events, paid events, or paid touches), to one decimal. `0` for `inconsistent_source_vocab`, which has no meaningful denominator — not a computed zero."},"samples":{"type":"array","items":{"$ref":"#/components/schemas/UtmAnomalySample"},"description":"Up to 5 of the most common offending values, most frequent first. Always present; `[]` for the categories that collect none (`unsubstituted_macro`, `missing_utms_on_paid`)."},"recommendation":{"type":"string","description":"The fix, in prose, written by the scan itself. Safe to show to an end user."},"recommendation_code":{"type":["string","null"],"example":"move_placement_to_utm_content","description":"The stable identifier for the remedy — ONE PER REMEDY, not one per `category`, so two categories can share a fix. Published alongside `recommendation` rather than instead of it: that sentence is composed inside the database function, so it is English on every screen and names warehouse tables. Key your own translated copy off this and fall back to the prose.\n\n`null` on a breakdown cached before the codes existed — old rows are not rewritten. That means 'no code was stored', never 'no remedy'."}},"required":["severity","category","field","count","pct","samples","recommendation","recommendation_code"]},"UtmAnomalies":{"type":"object","properties":{"scan_window_days":{"type":"number"},"refreshed_at":{"type":["string","null"],"description":"When this BREAKDOWN was last computed. Not the same stamp as the badge's `refreshed_at` in GET /api/v1/quality/utm: that one also moves when the cheap daily cron recounts, and the cron does not recompute the breakdown. `null` means no deep scan has ever cached a breakdown for this window — which is NOT the same as a scan that found nothing, so do not render an empty list with a null stamp as 'clean'."},"anomalies":{"type":"array","items":{"$ref":"#/components/schemas/UtmAnomaly"},"description":"One entry per DISTINCT ISSUE, at most six. This is not `high_count + medium_count`: those count events, this counts issues, so one entry can carry a `count` in the thousands."}},"required":["scan_window_days","refreshed_at","anomalies"]},"ExportDestinationWriteRequest":{"type":"object","properties":{"destination":{"type":"string","enum":["meta_capi","google_ads"],"description":"Which platform. Required on create; IMMUTABLE — a PATCH that changes it is rejected."},"connection_id":{"type":"string","format":"uuid","description":"The `data_connections` row this destination ships through. Must belong to this profile and its provider must match the kind (`meta_capi` ⇒ `meta_ads`)."},"enabled":{"type":"boolean","description":"Default true on create."},"meta_pixel_id":{"type":["string","null"],"maxLength":128,"example":"1234567890","description":"Meta dataset / pixel id. REQUIRED for `meta_capi`."},"google_conversion_action":{"type":["string","null"],"maxLength":256,"example":"customers/123/conversionActions/456","description":"Google Ads conversion action resource name. REQUIRED for `google_ads`."},"destination_config":{"type":"object","additionalProperties":{},"description":"Free-form destination settings (`signal_quality`, `relay_automatic_events`, …). DEEP-MERGED over the stored object on a PATCH, so keys written by the dataset wizard (`data_source`) and by Platform-Safe provisioning are never blanked by a partial update."}},"required":["destination","connection_id"]},"ExportRule":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"archived_at":{"type":["string","null"],"description":"Set to archive. An archived rule emits nothing and is excluded from the list."},"priority":{"type":"number","example":100,"description":"Lower runs first. 1-999."},"source_kind":{"type":"string","enum":["conversion_definition","pipeline_stage_transition"]},"source_ref_id":{"type":["string","null"],"description":"The `conversion_definitions` id a `conversion_definition` rule maps. Null on a pipeline rule."},"source_config":{"type":"object","additionalProperties":{},"description":"`to_stage` / `from_stage` / `external_source` on a pipeline rule; `fixed_value` + `fixed_currency` on a `fixed_value` rule."},"trigger_mode":{"type":"string","enum":["event_recorded","stage_entered","stage_exited"]},"destinations":{"type":"object","additionalProperties":{},"example":{"meta_capi":{"enabled":true}},"description":"Per-destination toggle. A rule with no enabled destination produces no candidates."},"platform_event_name_overrides":{"type":"object","additionalProperties":{},"example":{"meta_capi":"Purchase"},"description":"Overrides the event name derived from the definition's source events."},"meta_custom_event_type":{"type":["string","null"],"example":"PURCHASE","description":"Meta's optimization category for the Custom Conversion created from this rule. Null means derive it. Meta's spellings are counter-intuitive — `INITIATED_CHECKOUT`, `CONTENT_VIEW`."},"value_mode":{"type":"string","enum":["event_value","fixed_value","none"]},"recurring_payment_mode":{"type":"string","enum":["skip","custom_event","purchase"],"example":"skip","description":"What this rule does with a SUBSCRIPTION RENEWAL — a `cash` `payment_received` conversion whose `is_first_payment` is false. `skip` (the default, and what every pre-existing rule was backfilled to) never exports it and records `skip_reason: \"recurring_payment\"` on the delivery. `custom_event` sends it to Meta as the custom event `SubscriptionRenewal` (Google Ads has no custom-event equivalent, so a Google destination skips with `recurring_payment_google_no_custom_event`). `purchase` is the pre-#1144 behaviour. FIRST payments are unaffected by every mode, as are refunds and every non-cash conversion."},"privacy_override":{"type":["object","null"],"additionalProperties":{},"description":"Per-rule privacy config layered over the profile's. Null inherits."},"created_at":{"type":"string"},"updated_at":{"type":"string"},"delivery_stats_7d":{"type":"object","properties":{"total":{"type":"number"},"sent":{"type":"number"},"lastAt":{"type":["string","null"]}},"required":["total","sent","lastAt"],"description":"Deliveries this rule produced in the last 7 days. An ENABLED rule sitting at zero is the most common broken state on this surface — it usually means the destination is disabled, the definition matches nothing, or the Meta event has no working Custom Conversion."}},"required":["id","name","enabled","archived_at","priority","source_kind","source_ref_id","source_config","trigger_mode","destinations","platform_event_name_overrides","meta_custom_event_type","value_mode","recurring_payment_mode","privacy_override","created_at","updated_at","delivery_stats_7d"]},"ExportRuleWriteRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":160},"source_kind":{"type":"string","enum":["conversion_definition","pipeline_stage_transition"],"description":"Required on create; IMMUTABLE — a PATCH that changes it is rejected."},"source_ref_id":{"type":["string","null"],"format":"uuid","description":"REQUIRED for a `conversion_definition` rule: the `conversion_definitions` id it maps."},"source_config":{"type":"object","additionalProperties":{}},"trigger_mode":{"type":"string","enum":["event_recorded","stage_entered","stage_exited"]},"enabled":{"type":"boolean"},"archived_at":{"type":["string","null"]},"priority":{"type":"integer","minimum":1,"maximum":999},"destinations":{"type":"object","additionalProperties":{}},"platform_event_name_overrides":{"type":"object","additionalProperties":{}},"meta_custom_event_type":{"type":["string","null"]},"value_mode":{"type":"string","enum":["event_value","fixed_value","none"]},"recurring_payment_mode":{"type":"string","enum":["skip","custom_event","purchase"],"description":"Defaults to `skip` on create: a renewal is not a `Purchase`, and Meta cannot unsend one. Set `purchase` only when the business genuinely wants renewals counted as purchases at the platform."},"privacy_override":{"type":["object","null"],"additionalProperties":{}}},"required":["name","source_kind"]},"ExportRulePreviewRequest":{"type":"object","properties":{"source_kind":{"type":"string","enum":["conversion_definition","pipeline_stage_transition"]},"source_ref_id":{"type":["string","null"],"format":"uuid"},"source_config":{"type":"object","additionalProperties":{}},"trigger_mode":{"type":"string","enum":["event_recorded","stage_entered","stage_exited"]},"platform_event_name_overrides":{"type":"object","additionalProperties":{}}},"required":["source_kind"]},"ExportRulePreview":{"type":"object","properties":{"days":{"type":"number","example":7},"candidate_count":{"type":"number","description":"Source records this rule would have matched in the window. An UPPER BOUND on its effect, never a forecast of sends: a matched record still has to clear identity, the privacy guard, the platform staleness window and destination routing."},"matched_on":{"type":"array","items":{"type":"string"},"example":["payment_received"],"description":"The definition's source event names, or the single configured stage."},"platform_event_name":{"type":"string","example":"Purchase"}},"required":["days","candidate_count","matched_on","platform_event_name"]},"ExportTestSendRequest":{"type":"object","properties":{"destination_id":{"type":["string","null"],"format":"uuid","description":"Which enabled meta_capi destination to send through. Omitted ⇒ the oldest enabled one."},"test_event_code":{"type":"string","maxLength":64,"example":"TEST12345","description":"From Events Manager → Test Events. Without it the event is a real (if synthetic) event on the dataset rather than a test one."},"rule_id":{"type":["string","null"],"format":"uuid","description":"Borrow this rule's Meta event name and privacy override, so the test exercises the rule you are verifying."},"channel":{"type":"string","enum":["website","business_messaging"],"description":"`business_messaging` sends a click-to-WhatsApp shaped event (sample `ctwa_clid`, `messaging_channel: whatsapp`)."}}},"ExportTestSend":{"type":"object","properties":{"meta_pixel_id":{"type":"string"},"event_id":{"type":"string","description":"The `event_id` the payload carried — search for it in Events Manager → Test Events."},"channel":{"type":"string"},"action_source":{"type":"string"},"filters_applied":{"type":"array","items":{"type":"string"},"description":"Privacy filters that fired on the cleanse before the send."},"privacy_mode":{"type":"string"},"quality_warning":{"type":["string","null"],"description":"Non-null when this profile's match-quality policy would BLOCK a real event carrying only what a test carries (IP + user agent). The test still sends — a test that could not run under a strict policy would prove nothing."},"meta_response":{"description":"Meta's response body, verbatim."}},"required":["meta_pixel_id","event_id","channel","action_source","filters_applied","privacy_mode","quality_warning"]},"ExportDestination":{"type":"object","properties":{"id":{"type":"string"},"destination":{"type":"string","example":"meta_capi","description":"`meta_capi` or `google_ads`."},"enabled":{"type":"boolean"},"routing":{"type":"string","enum":["active","disabled","suppressed_platform_safe"],"description":"Whether this destination will actually ship on the next run. `suppressed_platform_safe` means enabled but deliberately silenced: in Platform-Safe mode the provisioned Clean Dataset is the sole Meta feed, so every other meta_capi destination is held back to avoid double-sending and re-contaminating the old dataset."},"meta_pixel_id":{"type":["string","null"],"description":"Meta dataset / pixel id. Null on a Google destination."},"data_source_kind":{"type":["string","null"],"example":"website","description":"`website` | `whatsapp` | `messenger` | `instagram`. Null on a Google destination."},"clean_dataset_id":{"type":["string","null"],"description":"The Clean Dataset id, when Platform-Safe provisioning stamped this destination."},"google_conversion_action":{"type":["string","null"]},"connection":{"type":["object","null"],"properties":{"id":{"type":"string"},"provider":{"type":"string","example":"meta_ads"},"status":{"type":"string","example":"connected"},"external_account_id":{"type":["string","null"],"description":"The connected ad account id (Google Ads customer id on a Google destination)."},"external_account_name":{"type":["string","null"]}},"required":["id","provider","status","external_account_id","external_account_name"],"description":"The provider connection this destination ships through. Its `status` is the usual reason a healthy-looking destination stops delivering. No credential is ever included."},"last_delivery":{"type":["object","null"],"properties":{"at":{"type":"string"},"status":{"type":"string"}},"required":["at","status"],"description":"The most recent ledger row for this destination. Null if it has never produced one."},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","destination","enabled","routing","meta_pixel_id","data_source_kind","clean_dataset_id","google_conversion_action","connection","last_delivery","created_at","updated_at"]},"ExportDestinations":{"type":"object","properties":{"privacy_mode":{"type":"string","example":"standard","description":"`standard` | `hipaa` | `custom` | `platform_safe`. Governs routing and the legal gate."},"export_meta_enabled":{"type":"boolean"},"export_google_enabled":{"type":"boolean"},"destinations":{"type":"array","items":{"$ref":"#/components/schemas/ExportDestination"}}},"required":["privacy_mode","export_meta_enabled","export_google_enabled","destinations"]},"ExportCounts":{"type":"object","properties":{"scoped":{"type":"number","description":"Source records the request resolved to."},"enqueued":{"type":"number","description":"Ledger rows this call scheduled. NOT a count of events delivered."},"already_delivered":{"type":"number","description":"Already at the platform under their canonical event id — including via a sibling ingestion path. Never re-sent."},"already_queued":{"type":"number","description":"A job was already coming for these before this call. This is why repeating a trigger is a no-op."},"not_ledgered":{"type":"number","description":"In scope, but the pipeline has never built an export candidate for them. A standard export run is queued to build them."}},"required":["scoped","enqueued","already_delivered","already_queued","not_ledgered"]},"ExportBatch":{"type":"object","properties":{"id":{"type":"string","description":"The batch id. Pass it to `GET /api/v1/exports/{id}`."},"status":{"type":"string","enum":["queued","nothing_to_do"]},"requested_at":{"type":"string"},"destination":{"type":["string","null"]},"date_from":{"type":["string","null"]},"date_to":{"type":["string","null"]},"conversion_ids":{"type":["array","null"],"items":{"type":"string"}},"counts":{"$ref":"#/components/schemas/ExportCounts"},"queued_job_ids":{"type":"array","items":{"type":"number"},"description":"Queue message ids, for correlating with worker logs. Not durable identifiers."}},"required":["id","status","requested_at","destination","date_from","date_to","conversion_ids","counts","queued_job_ids"]},"ExportBatchStatus":{"allOf":[{"$ref":"#/components/schemas/ExportBatch"},{"type":"object","properties":{"progress":{"type":"object","additionalProperties":{"type":"number"},"example":{"pending":2,"sent":5},"description":"LIVE per-status counts of the ledger rows this batch queued, re-read at request time. Keys are the conversion_exports status ladder (pending | processing | sent | failed | skipped | dead_letter). Absent statuses are absent, not zero."},"progress_missing":{"type":"number","description":"Rows this batch queued that no longer exist (a destination was deleted and cascaded)."}},"required":["progress","progress_missing"]}]},"ExportTriggerRequest":{"type":"object","properties":{"destination":{"type":"string","enum":["meta_capi","google_ads"],"description":"Restrict to one destination kind. Omitted ⇒ every enabled destination."},"date_from":{"type":"string","example":"2026-08-18","description":"Inclusive lower bound on the SOURCE conversion's event time."},"date_to":{"type":"string","example":"2026-08-25","description":"Inclusive upper bound on the SOURCE conversion's event time."},"conversion_ids":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1,"maxItems":500,"description":"`conversions.id` values. Mutually exclusive with the date range."},"limit_per_destination":{"type":"integer","minimum":1,"maximum":500,"description":"Ceiling handed to the export run, per destination. Default 100."},"require_approval":{"type":"boolean","description":"Ask a human before anything is queued. Nothing is written: the response is still `202`, but `data` is `{status: \"awaiting_approval\", handoff}` instead of a batch. Hand `handoff.url` to the person who owns the account and poll `GET /api/v1/handoffs/{id}`; approving runs this exact request through the same path and the hand-off's `result.export` carries the batch id. Rejecting settles it `cancelled` and queues nothing. The legal gate is still checked first, so an approval is never handed out for a send this profile is not allowed to make.","example":true}}},"ExportLedgerEntry":{"type":"object","properties":{"id":{"type":"string"},"destination":{"type":"string"},"destination_config_id":{"type":["string","null"]},"rule_id":{"type":["string","null"]},"status":{"type":"string","enum":["pending","processing","sent","failed","skipped","dead_letter"]},"eligibility_status":{"type":["string","null"]},"platform_eligibility_status":{"type":"string"},"platform_eligibility_reason":{"type":["string","null"]},"skip_reason":{"type":["string","null"]},"platform_event_name":{"type":["string","null"],"example":"Purchase"},"canonical_event_id":{"type":["string","null"],"description":"The platform `event_id` this delivery carries — the booking cluster's canonical id. Two ingestion paths for one real conversion share it, which is both how Pixel↔CAPI deduplication works and how 'already delivered' is decided."},"cluster_id":{"type":["string","null"]},"source_record_type":{"type":"string","example":"outcome_event"},"source_record_id":{"type":["string","null"]},"outcome_event_id":{"type":["string","null"]},"attempt_count":{"type":"number"},"error_class":{"type":["string","null"],"enum":["auth","rate_limit","timeout","partial","bad_request","generic",null],"description":"A CLASS, never the provider's raw message: those routinely quote the rejected request back, personal data included."},"response_meta":{"type":["object","null"],"properties":{"http_status":{"type":["number","null"]},"trace_id":{"type":["string","null"],"description":"Meta `fbtrace_id` or Google `request-id`."},"latency_ms":{"type":["number","null"]},"events_received":{"type":["number","null"]},"events_total":{"type":["number","null"]}},"required":["http_status","trace_id","latency_ms","events_received","events_total"]},"last_attempted_at":{"type":["string","null"]},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","destination","destination_config_id","rule_id","status","eligibility_status","platform_eligibility_status","platform_eligibility_reason","skip_reason","platform_event_name","canonical_event_id","cluster_id","source_record_type","source_record_id","outcome_event_id","attempt_count","error_class","response_meta","last_attempted_at","created_at","updated_at"]},"CashCoverageBucket":{"type":"object","properties":{"count":{"type":"number","description":"Cash conversions in this bucket."},"value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Their gross value in `currency`."}},"required":["count","value"]},"CashCoverage":{"type":"object","properties":{"model":{"type":"string","example":"last_touch"},"currency":{"type":["string","null"],"description":"Reporting currency of every `value` below. Null when the window has no cash."},"total":{"$ref":"#/components/schemas/CashCoverageBucket"},"buckets":{"$ref":"#/components/schemas/CashCoverageBuckets"},"traceable_pct_by_count":{"type":"number"},"traceable_pct_by_value":{"type":"number"}},"required":["model","currency","total","buckets","traceable_pct_by_count","traceable_pct_by_value"],"description":"Where a profile's cash sits on the traceability spectrum, for the requested window and model. The five buckets are mutually exclusive and sum to `total` by both count and value. Settlement reversals (refunds, chargebacks) are excluded from the classification entirely — a reversal is not a unit of coverage in either direction."},"CashCoverageBuckets":{"type":"object","properties":{"ad_attributed":{"allOf":[{"$ref":"#/components/schemas/CashCoverageBucket"},{"description":"Credited touch resolves to a navigable ad. THE traceable bucket."}]},"inherited":{"allOf":[{"$ref":"#/components/schemas/CashCoverageBucket"},{"description":"Recovered through the lead→cash bridge — ad-attributed but a lower-confidence tier, deliberately excluded from `traceable_pct_*` and from ROAS."}]},"dangling_ad":{"allOf":[{"$ref":"#/components/schemas/CashCoverageBucket"},{"description":"Credited to an ad id that resolves to no known ad (deleted ad, no account access, or utm_content carrying an ad name instead of its id). The credit is real; it just cannot be placed in the ad hierarchy."}]},"organic_direct":{"allOf":[{"$ref":"#/components/schemas/CashCoverageBucket"},{"description":"Has credited touches, none carrying ad signal."}]},"unattributed":{"allOf":[{"$ref":"#/components/schemas/CashCoverageBucket"},{"description":"No credited touch of any kind."}]}},"required":["ad_attributed","inherited","dangling_ad","organic_direct","unattributed"]},"Instrumentation":{"type":"object","properties":{"window_days":{"type":"number","description":"The window actually measured."},"score":{"type":"number","description":"0-100 rolled-up health score."},"verdict":{"type":"string","enum":["healthy_tracking","broken_tracking"]},"tracking_healthy":{"type":"boolean","description":"Is the website tracker firing AND identity stitching? This is the disambiguation behind a low `cash_coverage.traceable_pct_by_value`."},"coverage_interpretation":{"type":"string","enum":["low_coverage_is_organic_or_checkout_gap","broken_tracking"]},"signals":{"$ref":"#/components/schemas/InstrumentationSignals"},"missing":{"type":"array","items":{"type":"string"},"description":"Machine-readable names of the dead or incomplete signals above."}},"required":["window_days","score","verdict","tracking_healthy","coverage_interpretation","signals","missing"],"description":"Whether the measurement itself can be trusted. Read it BEFORE acting on `cash_coverage`: a low traceable percentage means 'you have great organic' when `tracking_healthy` is true and 'your tracking is broken' when it is false, and the two call for opposite responses."},"InstrumentationSignals":{"type":"object","properties":{"tracker":{"type":"object","properties":{"status":{"type":"string","enum":["live","sparse","missing","connected","no_cash"]},"web_touches":{"type":"number"},"synthetic_touches":{"type":"number"},"last_web_touch":{"type":["string","null"]},"fresh":{"type":"boolean","description":"A web touch inside the last 7 days."}},"required":["status","web_touches","synthetic_touches","last_web_touch","fresh"]},"identify":{"type":"object","properties":{"status":{"type":"string","enum":["live","sparse","missing","connected","no_cash"]},"anon_stitches":{"type":"number"}},"required":["status","anon_stitches"]},"click_ids":{"type":"object","properties":{"status":{"type":"string","enum":["live","sparse","missing","connected","no_cash"]},"clickid_events":{"type":"number"}},"required":["status","clickid_events"]},"checkout_metadata":{"type":"object","properties":{"status":{"type":"string","enum":["live","sparse","missing","connected","no_cash"]},"cash_events":{"type":"integer"},"cash_events_with_ad_signal":{"type":"integer"}},"required":["status","cash_events","cash_events_with_ad_signal"]},"payment_provider":{"type":"object","properties":{"status":{"type":"string","enum":["live","sparse","missing","connected","no_cash"]},"live":{"type":"boolean"},"providers":{"type":"array","items":{"type":"object","properties":{"provider":{"type":"string"},"status":{"type":"string"}},"required":["provider","status"]}}},"required":["status","live","providers"]},"fx_normalization":{"type":"object","properties":{"status":{"type":"string","enum":["ok","degraded"]},"pending_cash_conversions":{"type":"integer","description":"Cash conversions stuck without an FX-normalized reporting value — they read as 0 revenue everywhere else."}},"required":["status","pending_cash_conversions"]}},"required":["tracker","identify","click_ids","checkout_metadata","payment_provider","fx_normalization"]},"TraceableRoasTrend":{"type":"object","properties":{"window_days":{"type":"number","description":"The full window that was split in half."},"recent_roas":{"type":["number","null"],"description":"Ad-attributed ROAS over the recent half. Null when that half carried no ad spend — a ratio with a zero denominator is not 0."},"prior_roas":{"type":["number","null"],"description":"Ad-attributed ROAS over the prior half. Null on the same condition."},"recent_spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"recent_traceable_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"days_with_cash":{"type":"integer","description":"Distinct days across the FULL window carrying any ad-attributed cash — the density behind the two ratios. A trend over two cash days is not a trend, and this is how you can tell."}},"required":["window_days","recent_roas","prior_roas","recent_spend","recent_traceable_revenue","days_with_cash"],"description":"Half-over-half ad-attributed ROAS. 'Traceable' is the `ad_attributed` bucket and nothing else, so this ratio is never inflated by revenue no ad can be shown to have driven. Directional only — it is not a causal or incrementality claim."},"Trust":{"type":"object","properties":{"cash_coverage":{"$ref":"#/components/schemas/CashCoverage"},"instrumentation":{"$ref":"#/components/schemas/Instrumentation"},"traceable_roas_trend":{"$ref":"#/components/schemas/TraceableRoasTrend"}},"required":["cash_coverage","instrumentation","traceable_roas_trend"]},"AttributionQuality":{"type":"object","properties":{"total_conversions":{"type":"number","description":"Non-reversal conversions in the window. The denominator of `coverage_percent`. (The underlying RPC calls this column `total_events`; it has never counted raw events, so it is published under the name it means.)"},"with_full_utms":{"type":"number","description":"Conversions whose first or latest touch carries a campaign / ad-set / ad id or a `utm_campaign`."},"with_fbclid_only":{"type":"number","description":"No UTM or ad identifier, but an `fbclid` — placeable only through click-id recovery."},"with_no_tracking":{"type":"number","description":"Neither. These conversions cannot be attributed to an ad at all."},"coverage_percent":{"type":"number","description":"`(with_full_utms + with_fbclid_only) / total_conversions` as a percentage, to one decimal. 0 for an empty window."}},"required":["total_conversions","with_full_utms","with_fbclid_only","with_no_tracking","coverage_percent"]},"ProfileFreshness":{"type":"object","properties":{"recomputed_at":{"type":["string","null"],"description":"When the attribution projection last ran for this profile (the later of the touch and conversion projection watermarks). NULL means it has never run — returned as null rather than as a fabricated timestamp."},"status":{"type":"string","example":"ready","description":"`ready` once a conversions projection has run for this profile, `pending` before that. Treat any other value as opaque."}},"required":["recomputed_at","status"]},"OutcomeDefinition":{"type":"object","properties":{"event_key":{"type":"string","example":"appointment_booked"},"label":{"type":"string","example":"Descualificado"},"sort_order":{"type":"number"}},"required":["event_key","label","sort_order"]},"ConversionDefinition":{"type":"object","properties":{"id":{"type":"string"},"conversion_key":{"type":"string","example":"payment_received","description":"The identity a `goal` filter matches on — it equals `conversions.conversion_type`."},"display_name":{"type":"string"},"source_event_names":{"type":"array","items":{"type":"string"},"description":"The `outcome_events.event_type` values that produce this conversion."},"revenue_type":{"type":"string","example":"cash","description":"`cash` | `pipeline` | `gross`. Decides whether the value nets into revenue."},"attribution_eligible":{"type":"boolean","description":"False ⇒ the conversion is counted but never given attribution credit."},"lookback_window_days":{"type":"number"},"is_default":{"type":"boolean"}},"required":["id","conversion_key","display_name","source_event_names","revenue_type","attribution_eligible","lookback_window_days","is_default"]},"GoalDefinitions":{"type":"object","properties":{"outcome_definitions":{"type":"array","items":{"$ref":"#/components/schemas/OutcomeDefinition"}},"conversion_definitions":{"type":"array","items":{"$ref":"#/components/schemas/ConversionDefinition"}}},"required":["outcome_definitions","conversion_definitions"]},"OutcomeCounts":{"type":"object","properties":{"counts":{"type":"object","additionalProperties":{"type":"number"},"description":"`outcome_events.event_type` → how many landed in the window, summed across CRM pipelines. An event type with none in the window is ABSENT rather than present with 0.","example":{"lead_created":412,"appointment_booked":88,"payment_received":19}}},"required":["counts"]},"FilterDimensionValue":{"type":"object","properties":{"value":{"type":"string","description":"The exact string the filter predicates compare against. Send it back verbatim as `filter[<dimension>]=is:<value>`."},"label":{"type":"string","description":"Display label. Equal to `value` for every dimension but `goal`, which resolves the profile's own `display_name`."},"count":{"type":"number","description":"Touches (or, for `goal`, conversions) carrying the value in the window. A RANKING signal for the picker — most-used first — not a reportable metric; the numbers come from `/api/v1/breakdown` once the filter is applied."}},"required":["value","label","count"]},"FilterDimensionValues":{"type":"object","properties":{"dimension":{"type":"string"},"values":{"type":"array","items":{"$ref":"#/components/schemas/FilterDimensionValue"}}},"required":["dimension","values"]},"MessagingKpi":{"type":"object","properties":{"available":{"type":"boolean","description":"False is a normal answer, not an error — see `unavailable_reason`. Every metric below is null when false."},"unavailable_reason":{"type":["string","null"],"enum":["stale_window","no_messaging_data",null],"description":"`stale_window`: the requested range ends more than two days behind today, and the source is a ROLLING aggregate whose numbers describe \"the last N days from today\" — returning them under an older label would be a wrong number wearing a right one. `no_messaging_data`: this profile has no messaging-objective cohort with any intent-scored conversation in the window."},"score_window":{"type":["string","null"],"example":"28d","description":"Which rolling bucket answered — `7d` | `14d` | `28d` | `lifetime`, chosen from the length of the requested range."},"spend":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Messaging-cohort spend, in the profile's reporting currency."},"high_intent_conversations":{"type":["number","null"]},"intent_scored_conversations":{"type":["number","null"]},"messaging_conversations_started":{"type":["number","null"]},"cost_per_high_intent":{"type":["number","null"],"description":"`spend / high_intent_conversations`. NULL when none were high-intent — never 0, which would read as free."}},"required":["available","unavailable_reason","score_window","spend","high_intent_conversations","intent_scored_conversations","messaging_conversations_started","cost_per_high_intent"]},"ChannelRuleCondition":{"type":"object","properties":{"field":{"type":"string","enum":["utm_source","utm_medium","referrer_domain","click_id_key","landing_page_path","custom_payload_field"],"description":"The event attribute this condition tests."},"op":{"type":"string","enum":["is","is_not","contains","starts_with","ends_with","in","matches_regex","exists"],"description":"Not every op is valid for every field — `click_id_key` is presence-only (`is` / `is_not`) and `exists` applies to `custom_payload_field`. An impossible pair simply never matches; it is not an error."},"value":{"description":"A string for the scalar ops, a string ARRAY for `in`, and `{ field_name, expected_value? }` for `custom_payload_field`. Untyped here because the SQL evaluator has no discriminant to narrow on."}},"required":["field","op"]},"ChannelRuleConditions":{"type":"object","properties":{"all_of":{"type":"array","items":{"$ref":"#/components/schemas/ChannelRuleCondition"},"description":"EVERY condition must match. There is no `any_of`."}},"required":["all_of"]},"ChannelRule":{"type":"object","properties":{"id":{"type":"string"},"priority":{"type":"number","description":"Lower wins. The first rule that matches decides the channel."},"rule_name":{"type":"string"},"is_active":{"type":"boolean"},"source_pattern":{"type":["string","null"]},"medium_pattern":{"type":["string","null"]},"referrer_pattern":{"type":["string","null"]},"click_id_key":{"type":["string","null"]},"landing_page_pattern":{"type":["string","null"]},"custom_payload_field":{"type":["string","null"]},"custom_payload_value":{"type":["string","null"]},"conditions":{"type":["object","null"],"properties":{"all_of":{"type":"array","items":{"$ref":"#/components/schemas/ChannelRuleCondition"},"description":"EVERY condition must match. There is no `any_of`."}},"required":["all_of"],"description":"The STRUCTURED matcher. NULL on rules written before it existed — those match through the flat `*_pattern` columns above, which is why both are published."},"output_channel":{"type":"string","example":"Paid Social"},"output_channel_group":{"type":"string","example":"Paid"},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","priority","rule_name","is_active","source_pattern","medium_pattern","referrer_pattern","click_id_key","landing_page_pattern","custom_payload_field","custom_payload_value","conditions","output_channel","output_channel_group","created_at","updated_at"]},"ChannelTaxonomyEntry":{"type":"object","properties":{"id":{"type":"string"},"channel_name":{"type":"string","example":"Paid Social"},"channel_group":{"type":"string","example":"Paid"},"is_system":{"type":"boolean","description":"True for Atribu's built-ins, false for channels this workspace added."},"sort_order":{"type":"number"}},"required":["id","channel_name","channel_group","is_system","sort_order"]},"ChannelRules":{"type":"object","properties":{"rules":{"type":"array","items":{"$ref":"#/components/schemas/ChannelRule"},"description":"In EVALUATION order: `priority` ascending, then `created_at`."},"taxonomy":{"type":"array","items":{"$ref":"#/components/schemas/ChannelTaxonomyEntry"},"description":"System channels plus this workspace's own, ordered by `sort_order`. `output_channel` on every rule is one of these names."}},"required":["rules","taxonomy"]},"ChannelRuleSuggestion":{"type":"object","properties":{"suggested_channel":{"type":"string","example":"Email","description":"A heuristic guess from the sample's source / medium / referrer. A default for a form field, not a classification."},"suggested_conditions":{"type":["object","null"],"properties":{"all_of":{"type":"array","items":{"$ref":"#/components/schemas/ChannelRuleCondition"},"description":"EVERY condition must match. There is no `any_of`."}},"required":["all_of"],"description":"Ready to POST to `/api/v1/channel-rules/match-count` unchanged, and ready to save as a rule's `conditions`. NULL when every candidate field was empty."},"sessions":{"type":"number","description":"Unclassified sessions in this cluster over the window. The size of the problem, and the only reportable number here."},"sample_source":{"type":"string","description":"`(empty)` where the sessions carried no value — a real cluster, not a gap."},"sample_medium":{"type":"string"},"sample_referrer":{"type":"string"}},"required":["suggested_channel","suggested_conditions","sessions","sample_source","sample_medium","sample_referrer"]},"ChannelRuleSuggestions":{"type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/components/schemas/ChannelRuleSuggestion"}},"window_days":{"type":"number","description":"The window actually scanned, AFTER the Ephemeral Buffer clamp."}},"required":["suggestions","window_days"]},"ChannelRuleMatchCount":{"type":"object","properties":{"matches":{"type":"number"},"total":{"type":"number","description":"EVERY session in the window, not only unclassified ones — so `matches / total` is the share of ALL traffic the rule touches, which is what makes an over-broad rule visible before it is saved."},"window_days":{"type":"number"}},"required":["matches","total","window_days"]},"ChannelRuleTransition":{"type":"object","properties":{"from":{"type":"string","example":"Direct","description":"The channel today. `Direct` where the session has none."},"to":{"type":"string","example":"Email"},"count":{"type":"number"}},"required":["from","to","count"]},"ChannelRuleDiff":{"type":"object","properties":{"total":{"type":"number"},"changed":{"type":"number","description":"Sessions whose channel this rule set would CHANGE."},"unmatched":{"type":"number","description":"Sessions no rule in the set matches. They keep the channel they have."},"transitions":{"type":"array","items":{"$ref":"#/components/schemas/ChannelRuleTransition"},"description":"The changes, largest first."},"window_days":{"type":"number"}},"required":["total","changed","unmatched","transitions","window_days"]},"ConversionDefinitionSuggestion":{"type":"object","properties":{"event_type":{"type":"string","example":"trial_started"},"event_count":{"type":"number"},"revenue_sum":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"The raw `value_amount` total for these events. NOT revenue: whether it reaches ROAS depends on the `revenue_type` you choose."},"distinct_customers":{"type":"number"},"suggested_revenue_type":{"type":"string","example":"pipeline","description":"`cash` | `pipeline` | `gross`, guessed from the event NAME. A default for a form field, not a verdict — a house vocabulary defeats it routinely."},"suggested_display_name":{"type":"string","example":"Trial Started"},"suggested_attribution_eligible":{"type":"boolean","description":"Always `true` today. A default for a form field, not a verdict — flip it before saving if this event type should not count toward attribution."},"suggested_meta_event_name":{"type":["string","null"],"enum":["Lead","Schedule","Purchase","CompleteRegistration","Contact","SubmitApplication","StartTrial","Subscribe",null],"example":"Lead","description":"A Meta standard event name guessed from the event TYPE via an explicit exact-match list — never a regex or partial match. `null` when no confident guess exists; `CompleteRegistration` / `Contact` / `StartTrial` / `Subscribe` are valid values you may set explicitly, this heuristic just doesn't guess them yet."}},"required":["event_type","event_count","revenue_sum","distinct_customers","suggested_revenue_type","suggested_display_name","suggested_attribution_eligible","suggested_meta_event_name"]},"ConversionDefinitionSuggestions":{"type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/components/schemas/ConversionDefinitionSuggestion"}},"window_days":{"type":"number"}},"required":["suggestions","window_days"]},"ConversionDefinitionPreview":{"type":"object","properties":{"conversions":{"type":"number"},"revenue_sum":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"The RAW `outcome_events.value_amount` total for the named event types. NOT the revenue this definition would contribute: that depends on `revenue_type` (only `cash` nets into revenue) and on attribution eligibility, neither of which this preview applies. Label it \"value of matching events\"."},"distinct_customers":{"type":"number"},"window_days":{"type":"number"},"suggested_attribution_eligible":{"type":"boolean","description":"Always `true` today. A default for a form field, not a verdict — flip it before saving if this event type should not count toward attribution."},"suggested_meta_event_name":{"type":["string","null"],"enum":["Lead","Schedule","Purchase","CompleteRegistration","Contact","SubmitApplication","StartTrial","Subscribe",null],"example":"Purchase","description":"A Meta standard event name guessed from the event TYPE via an explicit exact-match list — never a regex or partial match. `null` when no confident guess exists; `CompleteRegistration` / `Contact` / `StartTrial` / `Subscribe` are valid values you may set explicitly, this heuristic just doesn't guess them yet. Guessed from `definition.source_event_names[0]`."}},"required":["conversions","revenue_sum","distinct_customers","window_days","suggested_attribution_eligible","suggested_meta_event_name"]},"ConversionDefinitionDiff":{"type":"object","properties":{"events_before":{"type":"number"},"events_after":{"type":"number"},"revenue_before":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"revenue_after":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"added_event_names":{"type":"array","items":{"type":"string"},"description":"Event names this edit ADDS to the definition."},"removed_event_names":{"type":"array","items":{"type":"string"},"description":"Event names it REMOVES. Unless another definition claims them, these stop being counted as conversions at all — the destructive half of an edit."},"revenue_type_before":{"type":["string","null"]},"revenue_type_after":{"type":["string","null"]},"lookback_before":{"type":["number","null"]},"lookback_after":{"type":["number","null"]},"attribution_eligible_before":{"type":["boolean","null"]},"attribution_eligible_after":{"type":["boolean","null"]},"window_days":{"type":"number"}},"required":["events_before","events_after","revenue_before","revenue_after","added_event_names","removed_event_names","revenue_type_before","revenue_type_after","lookback_before","lookback_after","attribution_eligible_before","attribution_eligible_after","window_days"]},"AuditEvent":{"type":"object","properties":{"id":{"type":"string"},"event_time":{"type":"string"},"entity":{"type":"string","enum":["channel_rules","conversion_definitions"],"description":"The requested entity, echoed, so a client merging two calls can tell them apart."},"profile_id":{"type":["string","null"],"description":"The profile the change was scoped to. Null for a workspace-level change."},"actor_user_id":{"type":["string","null"],"description":"Null for a change made by a machine — a key-authenticated write or a background reconcile. A real distinction, not a missing value."},"metadata":{"type":"object","additionalProperties":{},"description":"OPAQUE and versionless: whatever the writing path recorded. Render what you recognise, ignore the rest, and never compute from it."}},"required":["id","event_time","entity","profile_id","actor_user_id","metadata"]},"ChannelRuleCreate":{"type":"object","properties":{"priority":{"type":"integer","minimum":0,"maximum":10000,"description":"Lower wins. Rules are evaluated in ascending order and the first match stops the scan."},"rule_name":{"type":"string","minLength":1,"maxLength":120},"is_active":{"type":"boolean"},"source_pattern":{"type":["string","null"],"maxLength":255},"medium_pattern":{"type":["string","null"],"maxLength":255},"referrer_pattern":{"type":["string","null"],"maxLength":255},"click_id_key":{"type":["string","null"],"maxLength":255},"landing_page_pattern":{"type":["string","null"],"maxLength":255},"custom_payload_field":{"type":["string","null"],"maxLength":255},"custom_payload_value":{"type":["string","null"],"maxLength":255},"conditions":{"type":["object","null"],"properties":{"all_of":{"type":"array","items":{"$ref":"#/components/schemas/ChannelRuleCondition"},"description":"EVERY condition must match. There is no `any_of`."}},"required":["all_of"],"description":"The STRUCTURED matcher. An empty `all_of` is REJECTED — it would match every session, which is a catch-all written by accident. Send `null` to clear it and fall back to the flat `*_pattern` columns."},"output_channel":{"type":"string","minLength":1,"maxLength":120,"example":"Paid Social","description":"Must already exist in the channel taxonomy (system-wide or this workspace's). Create it first with `POST /api/v1/channel-taxonomy`."},"output_channel_group":{"type":"string","minLength":1,"maxLength":120,"example":"Paid"},"diff_summary":{"type":["object","null"],"properties":{"total":{"type":"number"},"changed":{"type":"number"},"unmatched":{"type":"number"}},"description":"The counts the caller saw in `POST /api/v1/channel-rules/diff` before saving. Recorded on the audit row so the change log can show what the author believed they were doing. Never re-derived server-side, and never used for anything but display."}},"required":["priority","rule_name","is_active","output_channel","output_channel_group"]},"ChannelRuleUpdate":{"type":"object","properties":{"priority":{"type":"integer","minimum":0,"maximum":10000,"description":"Lower wins. Rules are evaluated in ascending order and the first match stops the scan."},"rule_name":{"type":"string","minLength":1,"maxLength":120},"is_active":{"type":"boolean"},"source_pattern":{"type":["string","null"],"maxLength":255},"medium_pattern":{"type":["string","null"],"maxLength":255},"referrer_pattern":{"type":["string","null"],"maxLength":255},"click_id_key":{"type":["string","null"],"maxLength":255},"landing_page_pattern":{"type":["string","null"],"maxLength":255},"custom_payload_field":{"type":["string","null"],"maxLength":255},"custom_payload_value":{"type":["string","null"],"maxLength":255},"conditions":{"type":["object","null"],"properties":{"all_of":{"type":"array","items":{"$ref":"#/components/schemas/ChannelRuleCondition"},"description":"EVERY condition must match. There is no `any_of`."}},"required":["all_of"],"description":"The STRUCTURED matcher. An empty `all_of` is REJECTED — it would match every session, which is a catch-all written by accident. Send `null` to clear it and fall back to the flat `*_pattern` columns."},"output_channel":{"type":"string","minLength":1,"maxLength":120,"example":"Paid Social","description":"Must already exist in the channel taxonomy (system-wide or this workspace's). Create it first with `POST /api/v1/channel-taxonomy`."},"output_channel_group":{"type":"string","minLength":1,"maxLength":120,"example":"Paid"},"diff_summary":{"type":["object","null"],"properties":{"total":{"type":"number"},"changed":{"type":"number"},"unmatched":{"type":"number"}},"description":"The counts the caller saw in `POST /api/v1/channel-rules/diff` before saving. Recorded on the audit row so the change log can show what the author believed they were doing. Never re-derived server-side, and never used for anything but display."}}},"ChannelRuleWriteResult":{"type":"object","properties":{"rule":{"$ref":"#/components/schemas/ChannelRule"},"replay_queued":{"type":"boolean","description":"Whether the full-profile replay was queued. FALSE means the change is saved but every already-classified session still carries its old label until a replay runs."},"replay_error":{"type":["string","null"],"description":"Why the replay could not be queued. Null whenever `replay_queued` is true."}},"required":["rule","replay_queued","replay_error"]},"ChannelRuleDeleteResult":{"type":"object","properties":{"deleted":{"type":"boolean"},"replay_queued":{"type":"boolean","description":"Whether the full-profile replay was queued. FALSE means the change is saved but every already-classified session still carries its old label until a replay runs."},"replay_error":{"type":["string","null"],"description":"Why the replay could not be queued. Null whenever `replay_queued` is true."}},"required":["deleted","replay_queued","replay_error"]},"ChannelRulePriority":{"type":"object","properties":{"id":{"type":"string"},"priority":{"type":"number"}},"required":["id","priority"]},"ChannelRuleReorderResult":{"type":"object","properties":{"rules":{"type":"array","items":{"$ref":"#/components/schemas/ChannelRulePriority"},"description":"The two rules that moved, with their NEW priorities. Nothing else changed."},"replay_queued":{"type":"boolean","description":"Whether the full-profile replay was queued. FALSE means the change is saved but every already-classified session still carries its old label until a replay runs."},"replay_error":{"type":["string","null"],"description":"Why the replay could not be queued. Null whenever `replay_queued` is true."}},"required":["rules","replay_queued","replay_error"]},"ChannelTaxonomyWriteResult":{"type":"object","properties":{"channel":{"$ref":"#/components/schemas/ChannelTaxonomyEntry"}},"required":["channel"]},"ConversionDefinitionCreate":{"type":"object","properties":{"conversion_key":{"type":"string","minLength":1,"maxLength":120,"example":"payment_received","description":"The identity a `goal` filter matches on — it equals `conversions.conversion_type`. TAKEN VERBATIM, never derived from `display_name`: renaming a goal must not silently re-key every filter and saved report pointing at it."},"display_name":{"type":"string","minLength":1,"maxLength":160},"source_event_names":{"type":"array","items":{"type":"string","minLength":1,"maxLength":120},"minItems":1,"description":"The `outcome_events.event_type` values this definition claims. Trimmed and de-duplicated server-side. Must not overlap another definition on this profile — one event cannot produce two conversions."},"revenue_type":{"type":"string","minLength":1,"maxLength":60,"example":"cash","description":"`cash` | `pipeline` | `gross`. Decides whether the value nets into revenue."},"attribution_eligible":{"type":"boolean"},"lookback_window_days":{"type":"integer","minimum":1,"maximum":365},"is_default":{"type":"boolean"},"diff_summary":{"type":["object","null"],"properties":{"events_before":{"type":"number"},"events_after":{"type":"number"},"revenue_before":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"revenue_after":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"description":"The counts the caller saw in `POST /api/v1/goals/definitions/diff` before saving, recorded on the audit row verbatim. Display only — never re-derived server-side and never computed from."}},"required":["conversion_key","display_name","source_event_names","revenue_type","attribution_eligible","lookback_window_days"]},"ConversionDefinitionUpdate":{"type":"object","properties":{"conversion_key":{"type":"string","minLength":1,"maxLength":120,"example":"payment_received","description":"The identity a `goal` filter matches on — it equals `conversions.conversion_type`. TAKEN VERBATIM, never derived from `display_name`: renaming a goal must not silently re-key every filter and saved report pointing at it."},"display_name":{"type":"string","minLength":1,"maxLength":160},"source_event_names":{"type":"array","items":{"type":"string","minLength":1,"maxLength":120},"minItems":1,"description":"The `outcome_events.event_type` values this definition claims. Trimmed and de-duplicated server-side. Must not overlap another definition on this profile — one event cannot produce two conversions."},"revenue_type":{"type":"string","minLength":1,"maxLength":60,"example":"cash","description":"`cash` | `pipeline` | `gross`. Decides whether the value nets into revenue."},"attribution_eligible":{"type":"boolean"},"lookback_window_days":{"type":"integer","minimum":1,"maximum":365},"is_default":{"type":"boolean"},"diff_summary":{"type":["object","null"],"properties":{"events_before":{"type":"number"},"events_after":{"type":"number"},"revenue_before":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"revenue_after":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"description":"The counts the caller saw in `POST /api/v1/goals/definitions/diff` before saving, recorded on the audit row verbatim. Display only — never re-derived server-side and never computed from."}}},"ConversionDefinitionWriteResult":{"type":"object","properties":{"definition":{"$ref":"#/components/schemas/ConversionDefinition"},"replay_queued":{"type":"boolean","description":"Whether the full-profile replay was queued. FALSE means the change is saved but every already-classified session still carries its old label until a replay runs."},"replay_error":{"type":["string","null"],"description":"Why the replay could not be queued. Null whenever `replay_queued` is true."}},"required":["definition","replay_queued","replay_error"]},"ConversionDefinitionDeleteResult":{"type":"object","properties":{"deleted":{"type":"boolean"},"replay_queued":{"type":"boolean","description":"Whether the full-profile replay was queued. FALSE means the change is saved but every already-classified session still carries its old label until a replay runs."},"replay_error":{"type":["string","null"],"description":"Why the replay could not be queued. Null whenever `replay_queued` is true."}},"required":["deleted","replay_queued","replay_error"]},"JourneyEvent":{"type":"object","properties":{"source_row_id":{"type":"string","description":"The row this event came from — a touch id or an outcome-event id. Stable, and the second half of the keyset the cursor encodes, so it is safe to key a rendered list by."},"event_type":{"type":"string","example":"ad_interaction","description":"`session_start` | `ad_interaction` | `organic_interaction` | `outcome` — the complete set. ADR 0004 retired the `events_enriched` union, so `page_view` / `custom_event` / `engagement` can no longer arrive."},"event_name":{"type":"string","description":"The touch type for a touch (`synthetic`, `organic_interaction`, …) or the outcome's own `event_type` (`lead_created`, `payment_received`, …)."},"event_time":{"type":"string"},"session_id":{"type":["string","null"],"description":"The web session a touch belonged to. Null for synthetic and outcome rows."},"url":{"type":["string","null"]},"path":{"type":["string","null"]},"referrer_domain":{"type":["string","null"]},"channel":{"type":["string","null"]},"source":{"type":["string","null"],"description":"Raw `utm_source` (or the outcome's first-touch UTM), not the derived platform."},"medium":{"type":["string","null"]},"campaign":{"type":["string","null"]},"derived_source":{"type":["string","null"],"description":"Derived platform label — instagram / facebook / google / meta / direct."},"derived_placement":{"type":["string","null"],"description":"Normalized Meta placement token, e.g. `instagram_reels`. Always null today — the durable touch layer carries no placement dimension."},"value_amount":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"currency":{"type":["string","null"]},"device":{"type":["string","null"]},"browser":{"type":["string","null"]},"os":{"type":["string","null"]},"country":{"type":["string","null"]},"city":{"type":["string","null"]},"is_synthetic":{"type":"boolean","description":"True for an off-site (ad-platform-derived) touch — one Atribu inferred rather than observed on the website."},"event_payload":{"type":["object","null"],"additionalProperties":{},"description":"The row's own metadata, OPAQUE by design: its shape is per `event_type` (an `organic_interaction` carries `subtype` / `media_kind` / `text_excerpt`; an outcome carries `external_source` / `external_id`). Read defensively."},"ad_name":{"type":["string","null"]},"ad_set_name":{"type":["string","null"]},"campaign_name":{"type":["string","null"]},"creative_thumbnail_url":{"type":["string","null"],"description":"Always null today — the read layer does not resolve creative previews on this grain. Published as null rather than omitted so \"unresolved\" is never read as \"no creative\"."},"creative_body":{"type":["string","null"]},"creative_title":{"type":["string","null"]},"ad_platform_id":{"type":["string","null"],"description":"The platform's ad id — join against `GET /api/v1/campaigns?level=ad`."},"campaign_platform_id":{"type":["string","null"]},"conversion_id":{"type":["string","null"],"description":"Outcome rows only: the conversion this outcome became, or null if it never became one (no `conversion_definitions` row lists its `event_name`)."},"is_credited":{"type":["boolean","null"],"description":"Whether this TOUCH received credit under `model`. NULL on an outcome row — \"not applicable\", never `false`: credit belongs to a touch, and `false` on an outcome would read as \"this conversion was not credited\"."},"credit_share":{"type":["number","null"],"description":"Mean credit weight (0..1) across the conversions this touch was credited in."},"credited_revenue":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Attributed money for this touch, in the profile's reporting currency. ADDITIVE across a visitor's touches — the sum is what the model gave this person's journey."},"credited_conversions":{"type":["number","null"]},"credit_model":{"type":["string","null"],"example":"last_touch","description":"The attribution model the four fields above were computed under."}},"required":["source_row_id","event_type","event_name","event_time","session_id","url","path","referrer_domain","channel","source","medium","campaign","derived_source","derived_placement","value_amount","currency","device","browser","os","country","city","is_synthetic","event_payload","ad_name","ad_set_name","campaign_name","creative_thumbnail_url","creative_body","creative_title","ad_platform_id","campaign_platform_id","conversion_id","is_credited","credit_share","credited_revenue","credited_conversions","credit_model"]},"CustomerIdentifier":{"type":"object","properties":{"id_type":{"type":"string","example":"email","description":"`email` or `phone`."},"id_value":{"type":"string"},"is_primary":{"type":"boolean","description":"The value the customer profile is keyed by. Primary rows come first."}},"required":["id_type","id_value","is_primary"]},"CustomerSummary":{"type":"object","properties":{"first_name":{"type":["string","null"],"description":"The customer's given name (`customer_profiles.first_name`). Null for an anonymous visitor and for a customer the CRM never named. Published because a customer opened by deep link is not in the `GET /api/v1/customers` list that carries the name per row, and a header with nowhere to get it fell through to the raw email — or to a literal \"Unknown\" — while the column was populated.","example":"Vicente"},"last_name":{"type":["string","null"],"description":"The customer's family name (`customer_profiles.last_name`). Same rules as `first_name`.","example":"Ramirez"},"touch_count":{"type":"number","description":"Marketing touches over the visitor's WHOLE history. This replaces the retired pageview count (ADR 0006) rather than approximating it — a pageview number is not published at all, because a fabricated 0 would read as \"never browsed\"."},"total_cash_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Sum of the visitor's CASH conversions, in the profile's reporting currency. Cash only: pipeline and gross revenue types are counted elsewhere, and adding them here would double-count a deal that is both."},"time_to_completion_seconds":{"type":["number","null"],"description":"Seconds from the visitor's first touch to their first conversion of `goal`. NULL when either end is missing — 0 is a real answer here (converted at the same instant as the first touch) and has to stay distinguishable from \"no such journey\"."},"lead_quality_score":{"type":["number","null"],"description":"The score stored on this customer's `lead_created` event, or null when they have none or it was never scored. Read, never recomputed. Always null for an anonymous visitor."},"identifiers":{"type":"array","items":{"$ref":"#/components/schemas/CustomerIdentifier"},"description":"Email and phone only, primary first. The identity graph also holds anonymous ids and provider keys; those are plumbing, not identity, and are deliberately not published here. Empty for an anonymous visitor."}},"required":["first_name","last_name","touch_count","total_cash_revenue","time_to_completion_seconds","lead_quality_score","identifiers"]},"DmAdsDailyPoint":{"type":"object","properties":{"date":{"type":"string","example":"2026-03-25"},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"A decimal string like every other amount here, but NOT exact: this leaf lives inside a jsonb document, which PostgREST cannot cast per-read, so the double was already formed at the database edge. See the header of @atribu/api-contract/dto-messaging."},"high_intent":{"type":"integer"},"conversations_started":{"type":"integer"}},"required":["date","spend","high_intent","conversations_started"]},"DmAdsSampleConversation":{"type":"object","properties":{"id":{"type":"string"},"intent":{"type":"string","example":"ask_pricing"},"excerpt":{"type":"string","description":"The customer's first inbound message, redacted server-side (email, phone, RUT/CURP/DNI/cédula) and truncated to 140 characters."},"received_at":{"type":"string"},"confidence":{"type":"string","example":"high"},"depth":{"type":"integer"}},"required":["id","intent","excerpt","received_at","confidence","depth"]},"DmAdsBoardAd":{"type":"object","properties":{"ad_external_id":{"type":"string"},"ad_name":{"type":"string"},"campaign_name":{"type":["string","null"]},"ad_set_name":{"type":["string","null"]},"status":{"type":"string"},"channel":{"type":"string","example":"ig_ctm"},"recommended":{"type":"boolean"},"data_quality_tier":{"type":["string","null"]},"composite_score":{"type":["number","null"]},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"conversations_started":{"type":"number"},"high_intent_conversations":{"type":"number"},"high_intent_rate":{"type":"number"},"high_intent_band":{"type":"string","enum":["low","medium","high"]},"cost_per_conversation":{"type":["number","null"]},"cost_per_high_intent":{"type":["number","null"]},"first_reply_rate":{"type":"number"},"depth_3_rate":{"type":"number"},"attributed_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"roas":{"type":"number"},"booked_appointments":{"type":"number"},"closed_deals":{"type":"number"},"intent_breakdown":{"type":"array","items":{"type":"object","properties":{"intent":{"type":"string"},"count":{"type":"number"},"share":{"type":"number"}},"required":["intent","count","share"]}},"recommendations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"kind":{"type":"string"},"expected_impact_dollars":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"confidence":{"type":"number"},"snapshot_date":{"type":"string"},"rationale_text":{"type":["string","null"]},"external_action_url":{"type":["string","null"],"description":"Set on a manual-only `creative_refresh_pre_fatigue` rec — the Ads Lab session prepared for it. When present the rec is a hand-off, not something to apply."}},"required":["id","kind","expected_impact_dollars","confidence","snapshot_date","rationale_text","external_action_url"]}},"thumbnail_url":{"type":["string","null"]},"video_url":{"type":["string","null"]},"copy_text":{"type":["string","null"]},"narrative_arc":{"type":["string","null"]},"daily_trend":{"type":"array","items":{"$ref":"#/components/schemas/DmAdsDailyPoint"}},"reason_codes":{"type":"array","items":{"type":"object","properties":{"layer":{"type":"string"},"polarity":{"type":"string","enum":["strength","weakness","forecast","fatigue"]},"percentile":{"type":["number","null"]},"sample_confidence":{"type":"string","enum":["low","medium","high"]},"sample_n":{"type":"number","description":"Cohort ads the percentile was ranked against. A percentile without its sample is a rank presented as a fact; `sample_confidence` is the band derived from this number."},"code":{"type":"string"},"metric_value":{"type":["number","null"]}},"required":["layer","polarity","percentile","sample_confidence","sample_n"]}},"sample_conversations":{"type":["array","null"],"items":{"$ref":"#/components/schemas/DmAdsSampleConversation"},"description":"Redacted excerpts of the conversations this ad started. NULL — never [] — unless the key also holds `customers:read`; an empty array would be indistinguishable from 'this ad started no conversations'."}},"required":["ad_external_id","ad_name","campaign_name","ad_set_name","status","channel","recommended","data_quality_tier","composite_score","spend","conversations_started","high_intent_conversations","high_intent_rate","high_intent_band","cost_per_conversation","cost_per_high_intent","first_reply_rate","depth_3_rate","attributed_revenue","roas","booked_appointments","closed_deals","intent_breakdown","recommendations","thumbnail_url","video_url","copy_text","narrative_arc","daily_trend","reason_codes","sample_conversations"]},"DmAdsKpiSet":{"type":"object","properties":{"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"high_intent":{"type":"integer"},"conversations":{"type":"integer"},"cost_per_high_intent":{"type":"number","description":"spend ÷ qualified conversations — the board's hero metric."},"first_reply_rate":{"type":"number","description":"A PERCENTAGE, 0–100, not a share."},"roas":{"type":"number"}},"required":["spend","high_intent","conversations","cost_per_high_intent","first_reply_rate","roas"]},"DmAdsFunnel":{"type":"object","properties":{"impressions":{"type":"integer"},"clicks":{"type":"integer"},"conversations":{"type":"integer"},"high_intent":{"type":"integer"},"booked":{"type":"integer"},"closed":{"type":"integer"},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"click_rate":{"type":["number","null"]},"conversation_rate":{"type":["number","null"]},"high_intent_rate":{"type":["number","null"]},"booked_rate":{"type":["number","null"]},"closed_rate":{"type":["number","null"]}},"required":["impressions","clicks","conversations","high_intent","booked","closed","revenue","click_rate","conversation_rate","high_intent_rate","booked_rate","closed_rate"]},"DmAdsFunnelTrendPoint":{"type":"object","properties":{"date":{"type":"string","example":"2026-03-25"},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"high_intent":{"type":"integer"}},"required":["date","spend","high_intent"]},"DmAdsPattern":{"type":"object","properties":{"pattern_dim":{"type":"string","example":"narrative_arc"},"pattern_value":{"type":["string","null"]},"pattern_label":{"type":["string","null"]},"pattern_description":{"type":["string","null"]},"pattern_winning_signal":{"type":["string","null"]},"sample_n":{"type":"integer"},"winner_n":{"type":"integer"},"win_rate":{"type":"number"},"baseline_win_rate":{"type":"number","description":"The win rate across the same filtered population — this profile's messaging ads, not the workspace's."},"lift_vs_baseline":{"type":"number","description":"An ASSOCIATION, not a causal claim. Read it with the confidence interval; a point estimate alone overstates what the sample supports."},"confidence_interval_low":{"type":"number"},"confidence_interval_high":{"type":"number"},"exemplar_ad_external_ids":{"type":"array","items":{"type":"string"}}},"required":["pattern_dim","pattern_value","pattern_label","pattern_description","pattern_winning_signal","sample_n","winner_n","win_rate","baseline_win_rate","lift_vs_baseline","confidence_interval_low","confidence_interval_high","exemplar_ad_external_ids"]},"DmAdsBoard":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"prev_date_from":{"type":"string"},"prev_date_to":{"type":"string"},"meta_connected":{"type":"boolean","description":"Whether this profile has a connected `meta_ads` connection — the difference between 'nothing to show yet' and 'connected, and the messaging cohort is genuinely empty'."},"kpis":{"$ref":"#/components/schemas/DmAdsKpiSet"},"kpis_previous":{"$ref":"#/components/schemas/DmAdsKpiSet"},"ads":{"type":"array","items":{"$ref":"#/components/schemas/DmAdsBoardAd"}},"patterns":{"type":"array","items":{"$ref":"#/components/schemas/DmAdsPattern"}},"funnel":{"$ref":"#/components/schemas/DmAdsFunnel"},"funnel_trend":{"type":"array","items":{"$ref":"#/components/schemas/DmAdsFunnelTrendPoint"}}},"required":["date_from","date_to","prev_date_from","prev_date_to","meta_connected","kpis","kpis_previous","ads","patterns","funnel","funnel_trend"]},"PiiAccessLogEntry":{"type":"object","properties":{"id":{"type":"string"},"created_at":{"type":"string"},"profile_id":{"type":["string","null"],"description":"Null for a workspace- or user-scoped request — no profile in that context."},"principal_kind":{"type":"string","enum":["key","session","app"],"description":"Which credential made the request. Only key/session appear today — app-credential routes never declare a PII scope."},"principal_id":{"type":"string","description":"The key id, the signed-in user's id, or (reserved) an app's client_id, depending on principal_kind. Never the raw key, token, or secret."},"route_path":{"type":"string","example":"/api/v1/customers"},"route_method":{"type":"string","example":"GET"},"scopes":{"type":"array","items":{"type":"string"},"description":"The scopes THIS ROUTE declared as required, not the principal's full set."},"status":{"type":"number","example":200}},"required":["id","created_at","profile_id","principal_kind","principal_id","route_path","route_method","scopes","status"]},"ApiKeyUsage":{"type":"object","properties":{"api_key_id":{"type":"string","description":"Joins to `id` on GET /api/v1/workspaces/{workspaceId}/api-keys."},"requests":{"type":"number","example":1420},"errors_4xx":{"type":"number","description":"Requests answered 400–499 — the caller's own errors, including 429s."},"errors_5xx":{"type":"number","description":"Requests answered 500 or above. The SLO's error budget is spent here."},"p50_latency_ms":{"type":"number","example":210},"p95_latency_ms":{"type":"number","example":1180},"last_request_at":{"type":["string","null"],"description":"The most recent request inside the window, or null if there was none."}},"required":["api_key_id","requests","errors_4xx","errors_5xx","p50_latency_ms","p95_latency_ms","last_request_at"]},"ClientCredentialsMeta":{"type":"object","properties":{"client_id":{"type":"string","example":"atr_client_a1b2c3"}},"required":["client_id"]},"ErrorResponse":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string","example":"date_from is required"},"status":{"type":"number","example":400},"request_id":{"type":"string","example":"req_a1b2c3d4"},"docs_url":{"type":"string","description":"Deep link to the docs section explaining this exact code (#1065). Every Atribu response builder sets it; marked optional here (not required) for forward compatibility with any envelope a future surface emits before adopting it.","example":"https://www.atribu.app/docs/api/errors#invalid_parameter"},"required_scopes":{"type":"array","items":{"type":"string"},"description":"Present on `insufficient_scope` when the missing scope(s) are known by name. Mint a new key with one of these, or have a workspace admin grant a role that includes it.","example":["campaigns:read"]},"reconnect_required":{"type":"boolean","description":"Present + true when the connection's token was revoked and the user must re-authorize.","example":true},"reconnect_url":{"type":"string","description":"Where to send the user to reconnect (present with `reconnect_required` when the channel can build one)."},"upgrade_required":{"type":"boolean","description":"#1067 — present + true on a resource-limit denial (e.g. `profile_limit_reached`) the caller can fix by upgrading the plan or freeing up the resource.","example":true},"upgrade":{"type":"object","properties":{"plan":{"type":"string","description":"The plan the denial was evaluated against — not a suggested target.","example":"starter"}},"required":["plan"],"description":"Present when `upgrade_required` is true."}},"required":["code","message","status","request_id"]}},"required":["error"]},"Meta":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-03-01"},"date_to":{"type":"string","example":"2026-03-25"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]},"Pagination":{"type":"object","properties":{"has_next":{"type":"boolean"},"cursor":{"type":"string"}},"required":["has_next"]},"WhatsAppConversationMessage":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"wamid":{"type":["string","null"],"description":"The WhatsApp message id (`external_message_id`)."},"direction":{"type":"string","enum":["inbound","outbound"]},"message_type":{"type":"string","description":"text | image | video | audio | document | interactive | template."},"text_body":{"type":["string","null"]},"caption":{"type":["string","null"]},"media_id":{"type":["string","null"]},"media_mime_type":{"type":["string","null"]},"media_filename":{"type":["string","null"]},"interactive_type":{"type":["string","null"]},"interactive_payload":{},"template_name":{"type":["string","null"]},"status":{"type":"string"},"error_code":{"type":["string","null"]},"error_title":{"type":["string","null"]},"is_ai_generated":{"type":"boolean"},"message_timestamp":{"type":"string"}},"required":["id","wamid","direction","message_type","text_body","caption","media_id","media_mime_type","media_filename","interactive_type","template_name","status","error_code","error_title","is_ai_generated","message_timestamp"]},"DrillResult":{"oneOf":[{"type":"object","properties":{"kind":{"type":"string","enum":["first_payment"]},"rows":{"type":"array","items":{"type":"object","properties":{"conversion_id":{"type":"string"},"customer_profile_id":{"type":["string","null"]},"customer_name":{"type":"string"},"customer_email":{"type":["string","null"]},"amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"currency":{"type":["string","null"]},"paid_at":{"type":"string"},"channel":{"type":["string","null"]},"source":{"type":["string","null"]},"campaign_name":{"type":["string","null"]},"ad_name":{"type":["string","null"]},"ad_set_name":{"type":["string","null"]},"provider":{"type":["string","null"]},"first_touch_at":{"type":["string","null"]},"first_touch_source":{"type":["string","null"]},"last_touch_at":{"type":["string","null"]},"last_touch_source":{"type":["string","null"]},"session_count":{"type":"number"},"journey_days":{"type":"number"},"attributed_credit":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"The share of this conversion's value credited to the attributed touch under `model`. Credit-weighted, so it is ≤ `amount` on multi-touch models."},"confidence":{"type":"string","enum":["high","med","low"]},"payment_kind":{"type":["string","null"],"enum":["first","recurring",null],"description":"Whether the payment was the customer's first or a repeat. Only `attributed_revenue` classifies rows; `first_payment` leaves it null. NOT the drill kind — that is the envelope's `kind`."},"possible_duplicate":{"type":"boolean","description":"Flagged at ingest when a sibling charge for the same customer and amount landed within 5 minutes (Stripe Link autopay double-clicks). False on non-Stripe providers."}},"required":["conversion_id","customer_profile_id","customer_name","customer_email","amount","currency","paid_at","channel","source","campaign_name","ad_name","ad_set_name","provider","first_touch_at","first_touch_source","last_touch_at","last_touch_source","session_count","journey_days","attributed_credit","confidence","payment_kind","possible_duplicate"]}},"excluded":{"type":"array","items":{"type":"object","properties":{"conversion_id":{"type":"string"},"customer_profile_id":{"type":["string","null"]},"customer_name":{"type":"string"},"customer_email":{"type":["string","null"]},"amount":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"currency":{"type":["string","null"]},"paid_at":{"type":"string"},"exclude_kind":{"type":"string","enum":["refunded","recurring","other"]},"reason":{"type":"string"},"detail":{"type":"string"},"prior_payment_at":{"type":["string","null"]},"prior_payment_amount":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["conversion_id","customer_profile_id","customer_name","customer_email","amount","currency","paid_at","exclude_kind","reason","detail","prior_payment_at","prior_payment_amount"]},"description":"The payments the first-payment count deliberately left out, and why. Present only when `include=excluded` was sent; the same rows `kind=excluded_first` returns on its own."}},"required":["kind","rows"]},{"type":"object","properties":{"kind":{"type":"string","enum":["attributed_revenue"]},"rows":{"type":"array","items":{"type":"object","properties":{"conversion_id":{"type":"string"},"customer_profile_id":{"type":["string","null"]},"customer_name":{"type":"string"},"customer_email":{"type":["string","null"]},"amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"currency":{"type":["string","null"]},"paid_at":{"type":"string"},"channel":{"type":["string","null"]},"source":{"type":["string","null"]},"campaign_name":{"type":["string","null"]},"ad_name":{"type":["string","null"]},"ad_set_name":{"type":["string","null"]},"provider":{"type":["string","null"]},"first_touch_at":{"type":["string","null"]},"first_touch_source":{"type":["string","null"]},"last_touch_at":{"type":["string","null"]},"last_touch_source":{"type":["string","null"]},"session_count":{"type":"number"},"journey_days":{"type":"number"},"attributed_credit":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"The share of this conversion's value credited to the attributed touch under `model`. Credit-weighted, so it is ≤ `amount` on multi-touch models."},"confidence":{"type":"string","enum":["high","med","low"]},"payment_kind":{"type":["string","null"],"enum":["first","recurring",null],"description":"Whether the payment was the customer's first or a repeat. Only `attributed_revenue` classifies rows; `first_payment` leaves it null. NOT the drill kind — that is the envelope's `kind`."},"possible_duplicate":{"type":"boolean","description":"Flagged at ingest when a sibling charge for the same customer and amount landed within 5 minutes (Stripe Link autopay double-clicks). False on non-Stripe providers."}},"required":["conversion_id","customer_profile_id","customer_name","customer_email","amount","currency","paid_at","channel","source","campaign_name","ad_name","ad_set_name","provider","first_touch_at","first_touch_source","last_touch_at","last_touch_source","session_count","journey_days","attributed_credit","confidence","payment_kind","possible_duplicate"]}}},"required":["kind","rows"]},{"type":"object","properties":{"kind":{"type":"string","enum":["recurring_customer"]},"rows":{"type":"array","items":{"type":"object","properties":{"customer_profile_id":{"type":["string","null"]},"customer_name":{"type":"string"},"customer_email":{"type":["string","null"]},"recurring_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"recurring_payments":{"type":"integer"},"lifetime_payments":{"type":"integer"},"lifetime_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"first_payment_at":{"type":["string","null"]},"last_payment_at":{"type":["string","null"]},"avg_cadence_days":{"type":["number","null"]},"acquisition_channel":{"type":["string","null"]},"acquisition_source":{"type":["string","null"]},"acquisition_campaign":{"type":["string","null"]}},"required":["customer_profile_id","customer_name","customer_email","recurring_revenue","recurring_payments","lifetime_payments","lifetime_value","first_payment_at","last_payment_at","avg_cadence_days","acquisition_channel","acquisition_source","acquisition_campaign"]}}},"required":["kind","rows"]},{"type":"object","properties":{"kind":{"type":"string","enum":["refunded"]},"rows":{"type":"array","items":{"type":"object","properties":{"conversion_id":{"type":"string"},"customer_profile_id":{"type":["string","null"]},"customer_name":{"type":"string"},"customer_email":{"type":["string","null"]},"amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"currency":{"type":["string","null"]},"refunded_at":{"type":"string"},"external_source":{"type":"string"},"reason":{"type":["string","null"]}},"required":["conversion_id","customer_profile_id","customer_name","customer_email","amount","currency","refunded_at","external_source","reason"]}}},"required":["kind","rows"]},{"type":"object","properties":{"kind":{"type":"string","enum":["excluded_first"]},"rows":{"type":"array","items":{"type":"object","properties":{"conversion_id":{"type":"string"},"customer_profile_id":{"type":["string","null"]},"customer_name":{"type":"string"},"customer_email":{"type":["string","null"]},"amount":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"currency":{"type":["string","null"]},"paid_at":{"type":"string"},"exclude_kind":{"type":"string","enum":["refunded","recurring","other"]},"reason":{"type":"string"},"detail":{"type":"string"},"prior_payment_at":{"type":["string","null"]},"prior_payment_amount":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["conversion_id","customer_profile_id","customer_name","customer_email","amount","currency","paid_at","exclude_kind","reason","detail","prior_payment_at","prior_payment_amount"]}}},"required":["kind","rows"]},{"type":"object","properties":{"kind":{"type":"string","enum":["cac_by_channel"]},"rows":{"type":"array","items":{"type":"object","properties":{"channel":{"type":"string"},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"first_customers":{"type":"number"},"cac":{"type":["number","null"],"description":"Spend / first-time customers. NULL when the channel acquired none — never 0, which would read as free acquisition."}},"required":["channel","spend","first_customers","cac"]}}},"required":["kind","rows"]},{"type":"object","properties":{"kind":{"type":"string","enum":["spend_by_campaign"]},"rows":{"type":"array","items":{"type":"object","properties":{"campaign_id":{"type":["string","null"]},"platform_id":{"type":"string"},"campaign_name":{"type":"string"},"provider":{"type":["string","null"]},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"impressions":{"type":"number"},"clicks":{"type":"number"},"cpm":{"type":"number"},"cpc":{"type":"number"},"ctr":{"type":"number"},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"outcome_count":{"type":"number"},"roas":{"type":"number"},"prev_spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Spend over the immediately preceding window of equal length. Derived server-side; there is no `prev_*` request parameter."},"spend_change_pct":{"type":"number"}},"required":["campaign_id","platform_id","campaign_name","provider","spend","impressions","clicks","cpm","cpc","ctr","revenue","outcome_count","roas","prev_spend","spend_change_pct"]}}},"required":["kind","rows"]}]},"TrackingKey":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"name":{"type":"string","example":"default"},"status":{"type":"string","enum":["active","revoked"]},"public_key":{"type":"string","example":"trk_live_9f1c2b3a4d5e6f708192a3b4c5d6e7f8","description":"The tracking key embedded in the dealer's site — a PUBLIC identifier, not a secret. It ships inside the GTM tag / snippet / Shopify pixel to every anonymous visitor of the dealer's site, so returning it here is no more sensitive than returning the tag itself."},"last_seen_at":{"type":["string","null"],"description":"When the tracker last used this key to send an event. Null if never seen."},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","name","status","public_key","last_seen_at","created_at","updated_at"]},"IssuedTrackingKey":{"allOf":[{"$ref":"#/components/schemas/TrackingKey"},{"type":"object","properties":{"created":{"type":"boolean","description":"True when this call minted the key (HTTP 201). False when an existing active key was returned unchanged (HTTP 200) — see the route description for the idempotency contract."}},"required":["created"]}]},"IssueTrackingKeyRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80,"example":"vitrina-onboarding","description":"A label for the key, used only when this call actually mints one. Ignored on an idempotent replay — the existing active key keeps its original name."},"force_new":{"type":"boolean","example":false,"description":"Mint a key EVEN IF one is already active, and always answer 201. Default false — the idempotent behaviour described above. A profile may legitimately hold several active keys (which is why revoking the last one is refused); use this when rotating, and revoke the old key only after the new one is deployed."}}},"GtmInstaller":{"type":"object","properties":{"keyId":{"type":"string","example":"uuid"},"trackingKey":{"type":"string","example":"trk_live_9f1c2b3a4d5e6f708192a3b4c5d6e7f8"},"gtmTagName":{"type":"string","example":"Atribu - Tracker Bootstrap"},"gtmTrigger":{"type":"string","example":"All Pages"},"gtmTagSnippet":{"type":"string","description":"Paste this verbatim into a GTM Custom HTML tag. Defer-loads the Atribu tracker."},"setupChecklist":{"type":"array","items":{"type":"string"}}},"required":["keyId","trackingKey","gtmTagName","gtmTrigger","gtmTagSnippet","setupChecklist"]},"TrackingSnippet":{"type":"object","properties":{"keyId":{"type":"string","example":"uuid"},"snippet":{"type":"string","description":"The plain Atribu tracker snippet — paste before `</head>`."},"combinedSnippet":{"type":"string","description":"Present only when `include_meta_pixel=true` AND a Meta CAPI pixel is configured."},"metaPixelId":{"type":"string","description":"Present only alongside `combinedSnippet`."}},"required":["keyId","snippet"]},"ShopifyPixelInstaller":{"type":"object","properties":{"keyId":{"type":"string","example":"uuid"},"trackingKey":{"type":"string","example":"trk_live_9f1c2b3a4d5e6f708192a3b4c5d6e7f8"},"extensionPath":{"type":"string","example":"extensions/atribu-web-pixel/src/index.js"},"extensionCode":{"type":"string","description":"A Shopify Web Pixel extension `index.js` body. Replace the extension's source with this."},"setupChecklist":{"type":"array","items":{"type":"string"}}},"required":["keyId","trackingKey","extensionPath","extensionCode","setupChecklist"]},"ProvisionProfileRequest":{"type":"object","properties":{"external_ref":{"type":"string","minLength":1,"maxLength":200,"example":"dealer_8812","description":"YOUR id for this dealer. Together with your client_id it is the idempotency key: calling this endpoint again with the same external_ref returns the same profile (HTTP 200) instead of creating a second one. Required — there is no fallback, because a generated one would make every retry a new dealer."},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Autos del Valle","description":"The dealer's display name. Becomes both the workspace and the profile name."},"email":{"type":"string","format":"email","example":"owner@autosdelvalle.cl","description":"The dealer's owner email. A passwordless Atribu user is created for it and made owner of the dealer's workspace, so the dealer can claim the account later by magic link. This is IDENTITY, not the idempotency key — reusing one email across dealers is fine and still produces separate dealers."},"timezone":{"type":"string","minLength":1,"maxLength":64,"example":"America/Santiago","description":"IANA time zone. Defaults to UTC."},"currency":{"type":"string","minLength":3,"maxLength":3,"example":"CLP","description":"ISO-4217 reporting currency. Defaults to USD."},"site_domain":{"type":"string","maxLength":2000,"example":"https://www.autosdelvalle.cl","description":"The dealer's own website. Send it however you hold it — a bare host, a full URL, mixed case — and it is stored as one normal form: a lowercase hostname with no scheme, port or path (at most 253 characters), which is what `GET /api/v1/profile` returns as `site_domain`. `www.` is preserved, because `www.example.cl` and `example.cl` are different hosts. Rejected with a 400: IP addresses, single-label hosts, and URLs carrying credentials.\n\nThis records the dealer's SITE. It does not register a custom tracking host (no DNS is expected of you), and nothing about event collection is gated on it."},"locale":{"type":"string","enum":["en","es"],"example":"es","description":"The language the dealer's OWNER is written to in — alert digests and account email. Defaults to `en`. Applied only on the call that creates the dealer's owner: a language is a property of the person, and a later dealer sharing that email must not silently change it (nor overwrite what they chose themselves)."},"scopes":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":8,"example":["analytics","analytics_pii"],"description":"The delegation this app receives over the new profile. Defaults to [\"analytics\"]. Must be a subset of your app's allowed_scopes, and every entry must be a profile-level scope (analytics, analytics_pii, attribution_write) — a connection scope such as `whatsapp` is rejected, because a profile-level grant naming one would authorize nothing while appearing to."}},"required":["external_ref","name","email"]},"ProvisionedProfile":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"},"workspace_id":{"type":"string","example":"uuid"},"grant_id":{"type":"string","example":"uuid","description":"The oauth_app_authorizations row recording this delegation."},"created":{"type":"boolean","description":"True when this call created the dealer (HTTP 201). False when an existing (app, external_ref) profile was returned unchanged (HTTP 200)."},"scopes":{"type":"array","items":{"type":"string"},"example":["analytics"],"description":"The scopes the grant now carries."}},"required":["profile_id","workspace_id","grant_id","created","scopes"]},"SessionProvisionProfileRequest":{"type":"object","properties":{"workspace_id":{"type":"string","format":"uuid","example":"uuid","description":"The EXISTING workspace to add this profile to. The caller must hold an ACTIVE owner or admin membership on it — an analyst, guest, or non-member answers 404, identical to a workspace that does not exist."},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Downtown location"},"timezone":{"type":"string","minLength":1,"maxLength":64,"example":"America/Santiago"},"currency":{"type":"string","pattern":"^[A-Za-z]{3}$","example":"CLP"},"site_domain":{"type":"string","minLength":1,"maxLength":2000,"example":"autosdelvalle.cl","description":"Normalized server-side to a bare lowercase hostname."}},"required":["workspace_id","name"]},"SessionProvisionedProfile":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"},"workspace_id":{"type":"string","example":"uuid"},"created":{"type":"boolean","enum":[true],"description":"Always true — this branch has no idempotency key and always creates."}},"required":["profile_id","workspace_id","created"]},"ReportTemplate":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string","example":"Weekly performance"},"description":{"type":["string","null"]},"is_default":{"type":"boolean","description":"Exactly one template per workspace can be the default."},"schema_version":{"type":"number"},"sections":{"type":"array","items":{"type":"string"},"example":["cover","kpi_grid","revenue_chart"],"description":"The section ids this template declares, in order. The full template document (per-section config, commentary HTML) is not published — it is the editor's format, not a contract. An empty list means the template declares no nameable sections; it never means the document could not be read."},"schema":{"type":"object","additionalProperties":{},"description":"The template document — an ordered list of sections, each with its own per-type config. Not a stable public contract; consumers other than the Atribu console must not depend on its shape."},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","name","description","is_default","schema_version","sections","schema","created_at","updated_at"]},"ReportTemplateWrite":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"example":"Weekly performance"},"description":{"type":["string","null"]},"schema":{"type":"object","additionalProperties":{}},"is_default":{"type":"boolean"}},"required":["name","schema"]},"ReportTemplateUpdate":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"]},"schema":{"type":"object","additionalProperties":{}},"is_default":{"type":"boolean"}}},"ReportPreviewRequest":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-08-01"},"date_to":{"type":"string","example":"2026-08-31"},"model":{"type":"string","example":"last_touch"},"template_id":{"type":"string","description":"An existing, workspace-owned template. Mutually exclusive with `template_schema`."},"template_schema":{"type":"object","additionalProperties":{},"description":"An unsaved draft document. Mutually exclusive with `template_id`."}},"required":["date_from","date_to"]},"ReportSendTestRequest":{"type":"object","properties":{"recipients":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":10,"example":["ops@dealer.cl"],"description":"Defaults to the profile's own report-schedule recipients. A SESSION caller's explicit list is checked against workspace membership, the same rule `POST /reports/schedules` applies; a KEY caller's is not."},"window_days":{"type":"integer","minimum":1,"maximum":90,"example":7,"description":"The trailing window the test report covers. Defaults to 7."}}},"ReportSendTestResult":{"type":"object","properties":{"sent_count":{"type":"number"},"message_id":{"type":["string","null"],"description":"The email provider's message id. `null` means it returned none — the send happened, but no delivery event can ever be matched to it."}},"required":["sent_count","message_id"]},"SuppressedRecipient":{"type":"object","properties":{"email":{"type":"string"},"reason":{"type":"string","enum":["hard_bounce","complaint"],"description":"A soft/transient bounce never suppresses — only these two reasons do."},"suppressed_at":{"type":"string"}},"required":["email","reason","suppressed_at"]},"ReportSchedule":{"type":"object","properties":{"id":{"type":"string"},"enabled":{"type":"boolean"},"frequency":{"type":"string","enum":["daily","weekly","biweekly","monthly"]},"attribution_model":{"type":"string","example":"last_touch"},"day_of_week":{"type":["number","null"],"description":"0–6, Sunday-based. Null for cadences that pick no weekday."},"hour_utc":{"type":"number","example":8},"language":{"type":"string","enum":["en","es"],"description":"The language the report emails render in — the client's, not yours."},"template_id":{"type":["string","null"]},"recipient_count":{"type":"number","description":"How many addresses this schedule mails. Always returned."},"recipient_emails":{"type":["array","null"],"items":{"type":"string"},"example":["ops@dealer.cl"],"description":"The configured recipients. Returned ONLY to a key holding `reports:write` — a recipient list is configuration, not an analytic fact, so whoever may set it may read it. A key without that scope receives `null`, never `[]`: an empty array is a real and different state (a schedule configured to mail nobody). Use `recipient_count` to tell the two apart."},"suppressed_recipients":{"type":["array","null"],"items":{"$ref":"#/components/schemas/SuppressedRecipient"},"description":"Which of `recipient_emails` the email provider has told us are undeliverable. Same gate as `recipient_emails`, for the same reason — `null` for a key without `reports:write`, an array (possibly empty) for one with it. Scoped to this schedule's own addresses only, never the workspace's whole suppression list."},"last_sent_at":{"type":["string","null"]},"next_scheduled_at":{"type":["string","null"],"description":"Null whenever the schedule is disabled."}},"required":["id","enabled","frequency","attribution_model","day_of_week","hour_utc","language","template_id","recipient_count","recipient_emails","suppressed_recipients","last_sent_at","next_scheduled_at"]},"ReportScheduleWrite":{"type":"object","properties":{"enabled":{"type":"boolean"},"frequency":{"type":"string","enum":["daily","weekly","biweekly","monthly"]},"day_of_week":{"type":["integer","null"],"minimum":0,"maximum":6},"hour_utc":{"type":"integer","minimum":0,"maximum":23},"attribution_model":{"type":"string","example":"last_touch"},"language":{"type":"string","enum":["en","es"]},"template_id":{"type":["string","null"]},"recipient_emails":{"type":"array","items":{"type":"string"},"example":["ops@dealer.cl"],"description":"Up to 10 addresses. Enabling a schedule with none is rejected — it would mail nobody on every tick, forever, without telling anyone."}}},"ReportDelivery":{"type":"object","properties":{"id":{"type":"string"},"report_id":{"type":["string","null"]},"source":{"type":"string","enum":["manual","scheduled","api"],"description":"`manual` — a signed-in person clicked send. `scheduled` — the recurring cron. `api` — a delegated send with an API key. The CREDENTIAL decides, not the caller: a session bearer records `manual` with the person on it, a key records `api` (the actor is the key, which the request log carries)."},"recipient_count":{"type":"number"},"sent_at":{"type":"string"},"status":{"type":["string","null"],"enum":["sent","delivered","delivery_delayed","bounced","complained",null],"description":"`null` means UNKNOWN, not delivered: the provider returned no message id, so no delivery event can ever be matched to this send and its outcome will never be known. Never render a null as a success."},"status_updated_at":{"type":["string","null"]}},"required":["id","report_id","source","recipient_count","sent_at","status","status_updated_at"]},"Report":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":"string","example":"api","description":"`api` for a delegated generation, `custom` for one a signed-in person asked for, or the schedule's cadence word. Decided by the credential, never sent."},"date_from":{"type":"string"},"date_to":{"type":"string"},"model":{"type":"string"},"created_at":{"type":"string"},"share_slug":{"type":"string","description":"Slug of the hosted share page (`/reports/{share_slug}`), for linking a human to the rendered view. This API never serves that page."},"data":{"type":"object","additionalProperties":{},"description":"The report payload: KPIs, chart series, breakdowns, top campaigns, top performers. This is DATA, not presentation — the same document Atribu's own renderers consume, published so you can render your own report in your own product. No HTML and no PDF is returned by any endpoint on this surface."},"template_snapshot":{"type":["object","null"],"additionalProperties":{},"description":"The template document this report was rendered under, frozen at generation time. `null` only for rows generated before this field existed."}},"required":["id","type","date_from","date_to","model","created_at","share_slug","data","template_snapshot"]},"ReportSendResult":{"type":"object","properties":{"report_id":{"type":"string"},"sent_count":{"type":"number"},"message_id":{"type":["string","null"],"description":"The email provider's message id. `null` means it returned none — the send happened, but no delivery event can ever be matched to it, so the ledger row's `status` stays `null` forever."}},"required":["report_id","sent_count","message_id"]},"ReportSummary":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":"string","example":"custom","description":"`custom` (a signed-in person generated it), `api` (a delegated consumer did), or the schedule's cadence word."},"date_from":{"type":"string"},"date_to":{"type":"string"},"model":{"type":"string"},"created_at":{"type":"string"},"share_slug":{"type":"string"},"currency":{"type":"string","example":"CLP","description":"Frozen at generation — the report's own currency, not the profile's today."},"summary":{"type":["object","null"],"properties":{"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"roas":{"type":"number"},"outcomes":{"type":"number"}},"required":["spend","revenue","roas","outcomes"],"description":"The headline numbers, or `null` when the stored document carries none — a report predating a KPI, or one stored malformed. `null` is NOT zeros: rendering `0` for it would be inventing a measurement nobody took."}},"required":["id","type","date_from","date_to","model","created_at","share_slug","currency","summary"]},"ReportScheduleChange":{"type":"object","properties":{"id":{"type":"string"},"changed_at":{"type":"string"},"enabled":{"type":"boolean","description":"The state the schedule was left in by this change."},"frequency":{"type":"string","enum":["daily","weekly","biweekly","monthly"]},"recipient_count":{"type":"number","description":"How many addresses the schedule mailed after this change. The addresses themselves are not published here: they are configuration, and configuration rides `reports:write` (see `ReportSchedule.recipient_emails`)."}},"required":["id","changed_at","enabled","frequency","recipient_count"]},"PublicReportBranding":{"type":"object","properties":{"logo_url":{"type":["string","null"],"description":"The agency's logo, or null to render the Atribu wordmark."},"accent_color":{"type":["string","null"]},"hide_atribu_footer":{"type":"boolean","description":"Agency tier only. The entitlement is enforced when branding is resolved, never by the caller."},"branded":{"type":"boolean","description":"True when any white-label branding resolved. False means the report renders as Atribu's own."}},"required":["logo_url","accent_color","hide_atribu_footer","branded"]},"PublicSharedReport":{"type":"object","properties":{"type":{"type":"string","example":"weekly","description":"How this report came to exist — the schedule's cadence word, `custom`, or `api`."},"date_from":{"type":"string"},"date_to":{"type":"string"},"created_at":{"type":"string"},"document":{"type":["object","null"],"additionalProperties":{},"description":"The stored report, republished VERBATIM — the same document that was frozen at generation. `null` when the row carries none, which is a real state the renderer shows an empty message for; it is never `{}`, because a report of zeros would read as a measurement nobody took."},"template_snapshot":{"type":["object","null"],"additionalProperties":{},"description":"The template as it stood at generation — which sections, in which order. `null` for a report stored before snapshots existed."},"branding":{"$ref":"#/components/schemas/PublicReportBranding"}},"required":["type","date_from","date_to","created_at","document","template_snapshot","branding"]},"ReportsHub":{"type":"object","properties":{"rows":{"type":"array","items":{"$ref":"#/components/schemas/ReportsHubRow"}},"templates":{"type":"array","items":{"$ref":"#/components/schemas/ReportsHubTemplate"}}},"required":["rows","templates"]},"ReportsHubRow":{"type":"object","properties":{"profile_id":{"type":"string"},"profile_name":{"type":"string"},"has_schedule":{"type":"boolean","description":"False when the profile has no report_schedules row at all."},"schedule_enabled":{"type":"boolean"},"frequency":{"type":["string","null"],"enum":["daily","weekly","biweekly","monthly",null]},"recipient_count":{"type":"number"},"last_sent_at":{"type":["string","null"]},"next_scheduled_at":{"type":["string","null"],"description":"Null whenever the schedule is disabled — a stale timestamp never reads as upcoming."},"latest_report":{"$ref":"#/components/schemas/ReportsHubLatestReport"}},"required":["profile_id","profile_name","has_schedule","schedule_enabled","frequency","recipient_count","last_sent_at","next_scheduled_at","latest_report"]},"ReportsHubLatestReport":{"type":["object","null"],"properties":{"id":{"type":"string"},"type":{"type":"string"},"created_at":{"type":"string"},"date_range_start":{"type":"string"},"date_range_end":{"type":"string"}},"required":["id","type","created_at","date_range_start","date_range_end"]},"ReportsHubTemplate":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"is_default":{"type":"boolean"}},"required":["id","name","is_default"]},"ReportSchedulesBulkApply":{"type":"object","properties":{"templateId":{"type":["string","null"]},"enabled":{"type":"boolean"},"frequency":{"type":"string","enum":["daily","weekly","biweekly","monthly"]},"dayOfWeek":{"type":["integer","null"],"minimum":0,"maximum":6},"hourUtc":{"type":"integer","minimum":0,"maximum":23},"attributionModel":{"type":"string","example":"last_touch"},"language":{"type":"string","enum":["en","es"]}}},"ReportSchedulesBulkResult":{"type":"object","properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/ReportScheduleBulkResult"}},"succeeded":{"type":"number"},"failed":{"type":"number"}},"required":["results","succeeded","failed"]},"ReportScheduleBulkResult":{"type":"object","properties":{"profile_id":{"type":"string"},"ok":{"type":"boolean"},"error":{"type":"string"},"message":{"type":"string"},"created":{"type":"boolean"}},"required":["profile_id","ok"]},"ReportBulkGenerateResponse":{"type":"object","properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/ReportBulkGenerateResult"}},"generated":{"type":"number"},"emailed":{"type":"number"},"failed":{"type":"number"}},"required":["results","generated","emailed","failed"]},"ReportBulkGenerateResult":{"type":"object","properties":{"profile_id":{"type":"string"},"generated":{"type":"boolean"},"report_id":{"type":"string"},"emailed":{"type":"boolean"},"error":{"type":"string"}},"required":["profile_id","generated","emailed"]},"ProfilePlan":{"type":"object","properties":{"id":{"type":"string","example":"growth","description":"`subscriptions.plan_tier` — the key `PLANS` is indexed by. The plan's display name is an i18n key, so it is not returned: resolving it is the client's job."},"status":{"type":"string","example":"active","description":"`trialing` | `active` | `past_due` | `canceled` | `expired` | `paused`."}},"required":["id","status"]},"OnboardingStageMappingDraft":{"type":"object","properties":{"stages":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"event_key":{"type":"string"},"source_pipeline_name":{"type":["string","null"]},"mapping_confidence":{"type":["string","null"],"enum":["high","medium","low",null]}},"required":["name","event_key"]}},"outcome_sub_step":{"type":"string","enum":["pick_pipeline","review_mapping"]},"selected_pipeline_keys":{"type":"array","items":{"type":"string"}},"stages_dirty":{"type":"boolean"}},"required":["stages"]},"OnboardingState":{"type":"object","properties":{"version":{"type":"number","enum":[1]},"current_step":{"type":"integer","description":"The wizard step index (0-based) this draft last saved."},"business_type":{"type":"string","enum":["lead_gen","ecommerce","hybrid"]},"attribution_model":{"type":"string","description":"The draft attribution-model pick — not yet `default_attribution_model` until onboarding finishes."},"stage_mapping_draft":{"$ref":"#/components/schemas/OnboardingStageMappingDraft"},"tracking_substep":{"type":"string","enum":["custom_domain","meta_pixel","install","verify","install_shopify","verify_shopify"]},"updated_at":{"type":"string","description":"Stamped by the server on every `PATCH .../profile` write that includes `onboarding_state` — never the client's own clock."}},"required":["version","current_step","updated_at"]},"OnboardingSummary":{"type":"object","properties":{"completed":{"type":"boolean","description":"`profiles.onboarding_completed`."},"current_step":{"type":["integer","null"],"description":"Null before `onboarding_state`'s first write."},"updated_at":{"type":["string","null"],"description":"Null before `onboarding_state`'s first write."}},"required":["completed","current_step","updated_at"]},"ProfileContext":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"workspace_id":{"type":"string","example":"uuid"},"name":{"type":"string","example":"Clinica Norte"},"currency":{"type":"string","example":"CLP","description":"ISO 4217. Every money field this API returns for this profile is denominated in it."},"timezone":{"type":"string","example":"America/Santiago","description":"IANA zone. Day boundaries in every date range are computed in it."},"mode":{"type":["string","null"],"enum":["lead_gen","ecommerce","hybrid",null],"description":"How the profile makes money. Null when it was never set."},"site_domain":{"type":["string","null"],"example":"www.autosdelvalle.cl","description":"The dealer's own website, as a bare lowercase hostname — no scheme, port or path. Null when none was recorded. Set through `site_domain` on `POST /api/v1/profiles`. This is the dealer's SITE; it is not a custom tracking host, and nothing about event collection is gated on it."},"default_attribution_model":{"type":"string","example":"last_touch","description":"The attribution model a read uses when the caller names none."},"onboarding_completed":{"type":"boolean"},"onboarding":{"allOf":[{"$ref":"#/components/schemas/OnboardingSummary"},{"description":"#1056 — the reduced onboarding summary. Also on `WorkspaceProfile`."}]},"onboarding_state":{"allOf":[{"$ref":"#/components/schemas/OnboardingState"},{"type":["object","null"],"description":"#1056 — the full onboarding draft this profile last saved: current step, business type, the draft attribution-model pick, the GHL stage-mapping edits and the tracking sub-step. Null before its first `PATCH .../profile` write with `onboarding_state`. NEVER returned by `GET /api/v1/profiles` — see `WorkspaceProfile.onboarding` for the list route's reduced summary instead."}]},"permissions":{"type":"array","items":{"type":"string","enum":["view:dashboard","view:attribution","view:ads-explorer","view:ads-lab","view:calendar","view:journey","view:signals","view:data-quality","view:reports","view:messaging-attribution","view:settings","view:integrations","view:members","manage:reports","manage:ads-lab","manage:signals","manage:rules","manage:integrations","manage:settings","manage:members"]},"example":["view:dashboard","view:attribution"],"description":"What the CALLER may do with this profile, in Atribu's own permission vocabulary. For a session bearer these are the user's effective permissions on the profile; for an API key they are projected from the key's scopes. Advisory — it says what a UI should offer. The enforced gate is the key's scopes, which this API checks at the route and again at the read."},"plan":{"allOf":[{"$ref":"#/components/schemas/ProfilePlan"},{"type":["object","null"],"description":"The WORKSPACE's plan. Null when the workspace has no subscription row — reported rather than defaulted, because inventing a tier would make a feature gate assert something nobody configured. No prices, balances or invoice state are returned."}]},"is_demo":{"type":"boolean","description":"#1069 — this profile was seeded by `POST /api/v1/profiles/demo`. The attribution engine runs on its rows for real, so every profile-scoped read here returns real numbers computed by the real pipeline. What the flag changes is everything OUTSIDE the profile: it is excluded from workspace-grain aggregates (pacing, Top Performers cohorts, the agency Reports hub, alert digests, the ML passes), it does not consume an `active_profiles` plan seat, and its conversion exports are RECORDED — the `conversion_exports` row carries the exact payload Meta would have received, with `status: \"skipped\"` and `skip_reason: \"demo_profile_recorded\"` — never sent."}},"required":["id","workspace_id","name","currency","timezone","mode","site_domain","default_attribution_model","onboarding_completed","onboarding","onboarding_state","permissions","plan","is_demo"]},"WorkspaceProfile":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"name":{"type":"string","example":"Clinica Norte"},"currency":{"type":"string","example":"CLP"},"onboarding_completed":{"type":"boolean"},"onboarding":{"allOf":[{"$ref":"#/components/schemas/OnboardingSummary"},{"description":"#1056 — the same reduced summary `ProfileContext` carries. NEVER the full draft: see `ProfileContext.onboarding_state`."}]},"has_creative_scores":{"type":"boolean","description":"#487 — whether this profile has any creative scores at all. It separates 'no winners in this window' from 'never scored': two states a client would otherwise draw identically, and only one of which is a reason to go look at the ads."},"is_demo":{"type":"boolean","description":"#1069 — a seeded demo profile. Present on the LIST as well as on `ProfileContext` so a switcher can badge it without a second call; a client must never infer it from the name, which a person may change."}},"required":["id","name","currency","onboarding_completed","onboarding","has_creative_scores","is_demo"]},"WorkspaceSummary":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"name":{"type":"string","example":"Aristeia"},"workspace_role":{"type":"string","enum":["owner","admin","analyst","guest"],"description":"The CALLER's role in this workspace. Advisory — it says what a UI should offer; the enforced gate is the scope check this API performs at the route and again at the read."},"profile_count":{"type":"number","description":"Unarchived profiles in the workspace — the workspace's TOTAL, not the caller's accessible subset. The two are equal for owner/admin/analyst."},"default_timezone":{"type":"string","example":"America/Santiago","description":"`workspaces.default_timezone` — the IANA zone this workspace's days are cut on, NOT the caller's browser zone. Every date boundary on the surfaces behind this workspace uses it."},"acquisition_source":{"type":"string","example":"shopify_app_store","description":"`workspaces.acquisition_source` — how this workspace was created (`web`, `shopify_app_store`, `oauth`, …). The onboarding plan-selector gates on this being exactly `shopify_app_store` before it will render."},"member_count":{"type":"integer","description":"ACTIVE members of the workspace — the same TOTAL sense `profile_count` carries. A count, never the roster: who is in an agency is not published by the existence of the agency."},"brief":{"type":["object","null"],"properties":{"new_winners_count":{"type":"integer"},"at_risk_count":{"type":"integer"},"at_risk_spend_dollars":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"lift_backed_count":{"type":"integer","description":"Ads whose score is backed by an EXPERIMENT (`truth_grade='lift'`) rather than by a model or an attributed outcome. Normally small; that is the true state, not a bug."},"open_recs_count":{"type":"integer"},"open_recs_value_dollars":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"The summed `expected_impact_dollars` of the open recommendations. A FORECAST, never realised cash."},"reporting_currency":{"type":"string","example":"CLP","description":"ISO-4217 code the two amounts are denominated in — the WORKSPACE's `reporting_currency`, not any one profile's. Both sums add rows from profiles that may each report in a different currency, so every row is converted into this code before it is summed. There is no way to derive it from the response."},"fx_missing_rows":{"type":"integer","example":0,"description":"Rows dropped from the two sums because no exchange rate existed for their (currency, date) pair. Amounts are never converted at an assumed rate of 1, so an unconvertible row is excluded instead. A non-zero value means the totals are incomplete and should be presented as such."}},"required":["new_winners_count","at_risk_count","at_risk_spend_dollars","lift_backed_count","open_recs_count","open_recs_value_dollars","reporting_currency","fx_missing_rows"],"description":"The `28d` attention counters for this workspace — the same six `/workspaces/{workspaceId}/top-performers/brief` returns, from the multi-workspace twin of that RPC.\n\nA workspace with nothing scored gets the ZERO row, which is a real answer (\"nothing needs attention\"). `null` means the aggregate could NOT be computed — do not draw it as calm. It is nullable rather than fatal on purpose: this is the cold-boot read, and a workspace switcher that fails because the creative-scoring pipeline is unavailable is a worse outcome than a switcher with no indicators."}},"required":["id","name","workspace_role","profile_count","default_timezone","acquisition_source","member_count","brief"]},"WorkspaceSubscription":{"type":"object","properties":{"plan_tier":{"type":"string","example":"growth"},"billing_interval":{"type":["string","null"],"example":"month"},"status":{"type":"string","example":"active"},"billing_provider":{"type":["string","null"],"example":"stripe"},"current_period_start":{"type":["string","null"]},"current_period_end":{"type":["string","null"]},"trial_started_at":{"type":["string","null"]},"trial_ends_at":{"type":["string","null"]},"cancel_at_period_end":{"type":"boolean"},"canceled_at":{"type":["string","null"]},"limits":{"type":"object","properties":{"active_profiles":{"$ref":"#/components/schemas/WorkspaceActiveProfilesLimit"}},"required":["active_profiles"],"description":"The same `active_profiles` numbers `POST /api/v1/profiles` compares before answering `403 profile_limit_reached`."},"upgrade_available":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceUpgradeOption"},"description":"The plans ABOVE this one, cheapest first, each naming the route that mints a Stripe Checkout hand-off for it (#1052).\n\nAn EMPTY array is a real answer with three possible meanings: already on the top sellable plan, on a tier this catalogue does not rank (`enterprise` — its price is negotiated), or nothing above it. None is an error; none should render an upgrade prompt."}},"required":["plan_tier","billing_interval","status","billing_provider","current_period_start","current_period_end","trial_started_at","trial_ends_at","cancel_at_period_end","canceled_at","limits","upgrade_available"]},"WorkspaceActiveProfilesLimit":{"type":"object","properties":{"used":{"type":"integer","description":"Non-archived profiles in the workspace right now.","example":3},"max":{"type":["integer","null"],"description":"`null` when an admin or beta_tester override makes the cap moot — the workspace can never actually be denied on `active_profiles`.","example":5}},"required":["used","max"]},"WorkspaceUpgradeOption":{"type":"object","properties":{"plan":{"type":"string","example":"growth","description":"The plan id — a key of the published `PLANS` table."},"currency":{"type":"string","example":"USD","description":"ISO 4217, and the unit for both amounts below."},"price":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"99","description":"The monthly LIST price. Not this workspace's effective price."},"annual_monthly_price":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"79","description":"The per-MONTH list price when billed annually — the discounted rate, NOT the yearly total."},"checkout":{"$ref":"#/components/schemas/WorkspaceUpgradeCheckout"}},"required":["plan","currency","price","annual_monthly_price","checkout"]},"WorkspaceUpgradeCheckout":{"type":"object","properties":{"method":{"type":"string","example":"POST"},"path":{"type":"string","example":"/api/v1/workspaces/{workspaceId}/checkout-session","description":"A PATH, not a URL, so the same body is correct whichever host served it."}},"required":["method","path"]},"CreativePatternCluster":{"type":"object","properties":{"pattern_key":{"type":"string","example":"problem_first|video|book_now"},"hook_type":{"type":["string","null"]},"creative_format":{"type":["string","null"]},"cta_type":{"type":["string","null"]},"sample_tag":{"type":["string","null"]},"ad_count":{"type":"number"},"profile_count":{"type":"number"},"profile_ids":{"type":"array","items":{"type":"string"},"description":"Profiles in the workspace currently running this pattern."},"gap_profile_ids":{"type":"array","items":{"type":"string"},"description":"Profiles that are NOT running it — the actionable half, and the reason this read is workspace-grain. 'This works for your other clients and this one is not doing it' is not a question a single profile can answer."},"avg_composite_score":{"type":["number","null"]},"avg_smoothed_roas":{"type":["number","null"]},"roas_ci_low":{"type":["number","null"],"description":"Lower bound of the interval `avg_smoothed_roas` sits in. Render the lift WITH the interval; a point estimate presented alone is a fact the sample does not support."},"roas_ci_high":{"type":["number","null"]},"confidence_note":{"type":"string","example":"associational","description":"How the lift may be read. `associational` means exactly that: this pattern CO-OCCURS with better outcomes in this workspace. It is not a causal claim and not an experiment result."},"baseline_composite_score":{"type":["number","null"]},"baseline_smoothed_roas":{"type":["number","null"]},"lift_vs_baseline":{"type":["number","null"],"description":"The cluster's average over the workspace baseline. An association."},"example_ad_external_ids":{"type":"array","items":{"type":"string"}},"example_profile_ids":{"type":"array","items":{"type":"string"}}},"required":["pattern_key","hook_type","creative_format","cta_type","sample_tag","ad_count","profile_count","profile_ids","gap_profile_ids","avg_composite_score","avg_smoothed_roas","roas_ci_low","roas_ci_high","confidence_note","baseline_composite_score","baseline_smoothed_roas","lift_vs_baseline","example_ad_external_ids","example_profile_ids"]},"WorkspaceProfileSummary":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"name":{"type":"string","example":"Clinica Norte"},"currency":{"type":"string","example":"CLP"},"onboarding_completed":{"type":"boolean"},"has_creative_scores":{"type":"boolean","description":"Whether this profile has any creative scores at all. It separates 'no winners in this window' from 'never scored' — two states a client would otherwise draw identically, and only one of which is a reason to go look at the ads."}},"required":["id","name","currency","onboarding_completed","has_creative_scores"]},"AppWorkspaceRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80,"example":"Autos del Valle","description":"The workspace's display name."},"email":{"type":"string","format":"email","example":"owner@autosdelvalle.cl","description":"The workspace owner's email. A passwordless Atribu user is created for it (or reused, if your app already provisioned one) and made owner of the new workspace. This is IDENTITY, not an idempotency key — reusing one email across calls is fine and legitimately produces a separate workspace each time, not a collision."},"timezone":{"type":"string","minLength":1,"maxLength":64,"example":"America/Santiago","description":"IANA time zone. Defaults to UTC."},"locale":{"type":"string","enum":["en","es"],"example":"es","description":"The language the workspace's OWNER is written to in — alert digests and account email. Defaults to `en`. Applied only on the call that creates that owner's shadow user: a language is a property of the person, and a later call sharing that email must not silently change it."}},"required":["name","email"]},"SessionWorkspaceRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80,"example":"My Agency"},"timezone":{"type":"string","minLength":1,"maxLength":64,"example":"America/Santiago"},"plan":{"type":"string","enum":["starter","growth","agency"],"example":"growth","description":"Defaults to `starter` (Free). `growth` / `agency` immediately create a `trialing` subscription at that plan's real limits, rather than waiting for the workspace's first profile to bootstrap the Free tier, AND mint a real Stripe Checkout hand-off in `checkout` below — the human pays there while the trial already runs at the plan's real limits. `enterprise` is sales-led, not self-serve, and is not accepted here."}},"required":["name"]},"WorkspaceCheckoutHandoff":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"url":{"type":"string","description":"The Stripe Checkout hand-off URL — give this to a human to pay.","example":"https://api.atribu.app/h/AbCdEf..."},"expires_at":{"type":"string","example":"2026-09-05T18:00:00Z"}},"required":["id","url","expires_at"]},"WorkspaceCreationWarning":{"type":"object","properties":{"code":{"type":"string","example":"checkout_unavailable"},"message":{"type":"string"}},"required":["code","message"]},"CreatedWorkspace":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"},"name":{"type":"string","example":"My Agency"},"created":{"type":"boolean","description":"`false` on an idempotent replay of the session branch (same caller, same `name`, within a short window) — the status code carries the same signal (200 vs 201). Always `true` on the app-credential branch, which has no idempotency key."},"checkout":{"allOf":[{"$ref":"#/components/schemas/WorkspaceCheckoutHandoff"},{"type":["object","null"],"description":"A real Stripe Checkout hand-off for a session-branch `plan: \"growth\" | \"agency\"` request — poll `GET /api/v1/handoffs/{id}` (or send the human straight to `url`) the same way `POST /api/v1/workspaces/{workspaceId}/checkout-session` works. `null` for `starter`/absent, always `null` on the app-credential branch, and `null` when a mint was attempted but could not complete (see `warnings`) — workspace creation is never blocked on billing either way."}]},"warnings":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceCreationWarning"},"description":"Present only when something degraded without blocking creation — today, a single `checkout_unavailable` entry when `plan` asked for a checkout mint that did not happen. Omitted, not an empty array, when nothing degraded."},"mcp_writeback_enabled":{"type":"boolean","description":"`true` only when the session branch's caller is an MCP user token whose grant carries `mcp:write` — that person already consented to writes on the MCP OAuth screen and is this workspace's only member and owner, so it starts writable rather than requiring a human to flip a setting a `user_token` can never reach (`PUT /api/v1/workspaces/{id}/mcp-settings` stays refused to user tokens). Always `false` on the app-credential branch, a console session, or a `user_token` holding only `mcp:read`. On an idempotent replay this is the workspace's actual current value, not re-derived from this call."}},"required":["workspace_id","name","created","checkout","mcp_writeback_enabled"]},"WorkspaceArchiveCounts":{"type":"object","properties":{"profiles_archived":{"type":"integer"},"memberships_removed":{"type":"integer"},"invitations_revoked":{"type":"integer"},"api_keys_revoked":{"type":"integer"},"webhook_subscriptions_paused":{"type":"integer"},"retained_conversion_exports":{"type":"integer","description":"`conversion_exports` rows left attached to the archived workspace. Never deleted — a database trigger refuses, and they are the record of what Atribu transmitted."},"retained_signal_audit_rows":{"type":"integer","description":"`tracking_signal_audit_log` rows left attached to the archived workspace."}},"required":["profiles_archived","memberships_removed","invitations_revoked","api_keys_revoked","webhook_subscriptions_paused","retained_conversion_exports","retained_signal_audit_rows"]},"ArchivedWorkspace":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"},"archived":{"type":"boolean","description":"TRUE only when THIS call archived it. FALSE on a repeat — the workspace was already archived, `archived_at` is the original timestamp, and nothing changed. Both are 200."},"archived_at":{"type":"string","example":"2026-09-06T14:42:11.547Z"},"counts":{"$ref":"#/components/schemas/WorkspaceArchiveCounts"},"profiles_purged":{"type":"integer","description":"Profiles whose non-audit data this call purged."},"warnings":{"type":"array","items":{"type":"string"},"description":"Present only when something degraded. The workspace is archived either way — the archive commits in one transaction before any purge runs — so a purge that could not finish is reported here rather than as an error telling you to retry something that already succeeded. Omitted, not an empty array, when nothing degraded."}},"required":["workspace_id","archived","archived_at","counts","profiles_purged"]},"WorkspaceCreativePatternDimension":{"type":"object","properties":{"pattern_dim":{"type":"string","example":"hook_claim_cta","description":"Which creative dimension the row is about — `archetype`, `narrative_arc`, `dominant_third_combo`, `role_presence`, `social_proof_duration_bucket`, `hook_claim_cta`."},"pattern_value":{"type":"string","example":"problem_first"},"pattern_label":{"type":["string","null"]},"pattern_description":{"type":["string","null"]},"pattern_winning_signal":{"type":["string","null"],"description":"What the pattern appears to be doing well. Prose, never a measure."},"sample_n":{"type":"integer","description":"Ads carrying this value in the window — the denominator of `win_rate`."},"winner_n":{"type":"integer"},"win_rate":{"type":["number","null"],"description":"`winner_n / sample_n`. Meaningless without the baseline below."},"workspace_baseline_win_rate":{"type":["number","null"],"description":"The workspace's OWN win rate over the same window — the honest comparator. A 40% win rate is excellent against a 20% baseline and poor against a 60% one."},"lift_vs_workspace":{"type":["number","null"],"description":"`win_rate / workspace_baseline_win_rate`. An ASSOCIATION, never a cause, and never to be rendered without the interval below."},"confidence_interval_low":{"type":["number","null"]},"confidence_interval_high":{"type":["number","null"],"description":"Wilson 95% interval on `win_rate`. NULL when `sample_n` is 0."},"exemplar_ad_external_ids":{"type":"array","items":{"type":"string"}}},"required":["pattern_dim","pattern_value","pattern_label","pattern_description","pattern_winning_signal","sample_n","winner_n","win_rate","workspace_baseline_win_rate","lift_vs_workspace","confidence_interval_low","confidence_interval_high","exemplar_ad_external_ids"]},"WorkspaceCreativeArchetype":{"type":"object","properties":{"archetype_cluster":{"type":"integer","description":"The cluster's opaque id. Stable within a workspace and meaningless across one — never compare it between workspaces."},"label":{"type":["string","null"]},"description":{"type":["string","null"]},"winning_signal":{"type":["string","null"]},"n_ads":{"type":"integer"},"n_winners":{"type":"integer"},"win_rate":{"type":["number","null"],"description":"`winner_n / sample_n`. Meaningless without the baseline below."},"workspace_baseline_win_rate":{"type":["number","null"],"description":"The workspace's OWN win rate over the same window — the honest comparator. A 40% win rate is excellent against a 20% baseline and poor against a 60% one."},"lift_vs_workspace":{"type":["number","null"],"description":"`win_rate / workspace_baseline_win_rate`. An ASSOCIATION, never a cause, and never to be rendered without the interval below."},"confidence_interval_low":{"type":["number","null"]},"confidence_interval_high":{"type":["number","null"],"description":"Wilson 95% interval on `win_rate`. NULL when `sample_n` is 0."},"exemplar_ad_external_ids":{"type":"array","items":{"type":"string"}}},"required":["archetype_cluster","label","description","winning_signal","n_ads","n_winners","win_rate","workspace_baseline_win_rate","lift_vs_workspace","confidence_interval_low","confidence_interval_high","exemplar_ad_external_ids"]},"WorkspaceExperiment":{"type":"object","properties":{"experiment_id":{"type":"string"},"profile_id":{"type":"string"},"profile_name":{"type":["string","null"]},"meta_ad_study_id":{"type":["string","null"]},"name":{"type":"string"},"description":{"type":["string","null"]},"type":{"type":"string","example":"LIFT"},"derived_state":{"type":"string","example":"running","description":"Computed from the study's own timestamps rather than stored, so it cannot go stale against `start_time` / `end_time` / `observation_end_time`."},"start_time":{"type":["string","null"]},"end_time":{"type":["string","null"]},"observation_end_time":{"type":["string","null"]},"results_first_available_date":{"type":["string","null"]},"canceled_time":{"type":["string","null"]},"last_synced_at":{"type":["string","null"]},"last_results_fetched_at":{"type":["string","null"]},"n_cells":{"type":"integer"},"n_objectives":{"type":"integer"},"best_grade":{"type":["string","null"],"description":"Best `quality_grade` across this study's results; `A` is best."},"primary_incremental_conversions":{"type":["number","null"],"description":"Conversions Meta estimates would NOT have happened without the ad. A COUNT, not money and not a percentage — the underlying column is `experiment_results.conversions_incremental`. NULL until results land."},"primary_incremental_conversions_low":{"type":["number","null"]},"primary_incremental_conversions_high":{"type":["number","null"]},"primary_confidence":{"type":["number","null"],"description":"Confidence level the interval was computed at (e.g. 80, 90, 95)."},"primary_winner_cell_name":{"type":["string","null"],"description":"From the best `split_comparison` result — a DIFFERENT result row than the incremental estimate. A study can carry one without the other; they are not two views of one number."}},"required":["experiment_id","profile_id","profile_name","meta_ad_study_id","name","description","type","derived_state","start_time","end_time","observation_end_time","results_first_available_date","canceled_time","last_synced_at","last_results_fetched_at","n_cells","n_objectives","best_grade","primary_incremental_conversions","primary_incremental_conversions_low","primary_incremental_conversions_high","primary_confidence","primary_winner_cell_name"]},"WorkspaceTestRoadmapGap":{"type":"object","properties":{"profile_id":{"type":"string"},"profile_name":{"type":["string","null"]},"profile_scored_ad_count":{"type":"integer"},"profile_attributed_revenue":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"profile_spend":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"kind":{"type":"string","example":"cohort"},"target_dimension":{"type":"string"},"target_value":{"type":"string"},"test_key":{"type":"string","description":"The stable key a decision is recorded against — `(workspace, profile, score_window, test_key)`. Never the exemplar ad, which rotates on every rebuild."},"sample_n":{"type":"integer"},"winner_n":{"type":"integer"},"win_rate":{"type":["number","null"],"description":"`winner_n / sample_n`. Meaningless without the baseline below."},"workspace_baseline_win_rate":{"type":["number","null"],"description":"The workspace's OWN win rate over the same window — the honest comparator. A 40% win rate is excellent against a 20% baseline and poor against a 60% one."},"lift_vs_workspace":{"type":["number","null"],"description":"`win_rate / workspace_baseline_win_rate`. An ASSOCIATION, never a cause, and never to be rendered without the interval below."},"confidence_interval_low":{"type":["number","null"]},"confidence_interval_high":{"type":["number","null"],"description":"Wilson 95% interval on `win_rate`. NULL when `sample_n` is 0."},"exemplar_profile_id":{"type":"string"},"exemplar_profile_name":{"type":["string","null"]},"exemplar_ad_external_id":{"type":"string","description":"The concrete ad the evidence came from, so the suggestion can be checked."},"exemplar_roas":{"type":["number","null"],"description":"The exemplar's ROAS — a ratio over attributed cash. NULL when it has none."},"exemplar_composite_score":{"type":["number","null"],"description":"The exemplar's rule-blend score (0–100). NOT a probability and NOT cash — a separate field from `exemplar_roas` for the same reason the leaderboard keeps its three measures apart."},"exemplar_creative_format":{"type":["string","null"]},"exemplar_cohort_objective":{"type":["string","null"]},"exemplar_hook_type":{"type":["string","null"]},"exemplar_claim_style":{"type":["string","null"]},"exemplar_cta_type":{"type":["string","null"]},"exemplar_primary_angle":{"type":["string","null"]},"queued_status":{"type":["string","null"],"description":"`dismissed` | `drafted`, or null when nobody has decided yet."},"drafted_run_id":{"type":["string","null"]},"snapshot_date":{"type":"string"},"score_window":{"type":"string"}},"required":["profile_id","profile_name","profile_scored_ad_count","profile_attributed_revenue","profile_spend","kind","target_dimension","target_value","test_key","sample_n","winner_n","win_rate","workspace_baseline_win_rate","lift_vs_workspace","confidence_interval_low","confidence_interval_high","exemplar_profile_id","exemplar_profile_name","exemplar_ad_external_id","exemplar_roas","exemplar_composite_score","exemplar_creative_format","exemplar_cohort_objective","exemplar_hook_type","exemplar_claim_style","exemplar_cta_type","exemplar_primary_angle","queued_status","drafted_run_id","snapshot_date","score_window"]},"WorkspaceTestRoadmapCoverage":{"type":"object","properties":{"profile_id":{"type":"string"},"profile_name":{"type":["string","null"]},"scored_ad_count":{"type":"integer"}},"required":["profile_id","profile_name","scored_ad_count"]},"WorkspaceTestRoadmap":{"type":"object","properties":{"gaps":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceTestRoadmapGap"}},"coverage":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceTestRoadmapCoverage"}}},"required":["gaps","coverage"]},"WorkspaceTopPerformersBrief":{"type":"object","properties":{"new_winners_count":{"type":"integer"},"at_risk_count":{"type":"integer"},"at_risk_spend_dollars":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"lift_backed_count":{"type":"integer","description":"Ads whose score is backed by an EXPERIMENT (`truth_grade='lift'`) rather than by a model or an attributed outcome. Normally small; that is the true state, not a bug."},"open_recs_count":{"type":"integer"},"open_recs_value_dollars":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"The summed `expected_impact_dollars` of the open recommendations. A FORECAST, never realised cash."},"reporting_currency":{"type":"string","example":"CLP","description":"ISO-4217 code the two amounts are denominated in — the WORKSPACE's `reporting_currency`, not any one profile's. Both sums add rows from profiles that may each report in a different currency, so every row is converted into this code before it is summed. There is no way to derive it from the response."},"fx_missing_rows":{"type":"integer","example":0,"description":"Rows dropped from the two sums because no exchange rate existed for their (currency, date) pair. Amounts are never converted at an assumed rate of 1, so an unconvertible row is excluded instead. A non-zero value means the totals are incomplete and should be presented as such."}},"required":["new_winners_count","at_risk_count","at_risk_spend_dollars","lift_backed_count","open_recs_count","open_recs_value_dollars","reporting_currency","fx_missing_rows"]},"WorkspaceTabCount":{"type":"object","properties":{"tab_id":{"type":"string","example":"leaderboard"},"fresh_count":{"type":"integer"}},"required":["tab_id","fresh_count"]},"WorkspacePacingDay":{"type":"object","properties":{"day":{"type":"string","example":"2026-08-14"},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Spend that day, in the row's `currency`. It rides inside a jsonb document, so the column cast that keeps this surface's other amounts exact cannot reach it: the WIRE TYPE matches every other monetary field, but the digits are whatever a double already held. A display series, never a figure to reconcile."}},"required":["day","spend"]},"WorkspacePacing":{"type":"object","properties":{"profile_id":{"type":"string"},"profile_name":{"type":["string","null"]},"currency":{"type":"string","example":"CLP"},"mtd_spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"mtd_outcomes":{"type":"integer"},"monthly_budget_cents":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"In CENTS. NULL when no budget is set — a different state from a zero budget, and the one that makes `pace_spend_pct` meaningless."},"monthly_outcome_goal":{"type":["integer","null"]},"days_in_month":{"type":"integer"},"days_elapsed":{"type":"integer"},"pace_spend_pct":{"type":["number","null"],"description":"NOT percent-of-budget-spent. Spend so far over the budget PRO-RATED to `days_elapsed / days_in_month`, so `1.0` means on track today. NULL when no budget is set."},"pace_outcomes_pct":{"type":["number","null"]},"daily_spend":{"type":"array","items":{"$ref":"#/components/schemas/WorkspacePacingDay"},"description":"Trailing 28 days, oldest first."},"workspace_currency":{"type":"string","example":"USD","description":"The WORKSPACE's reporting currency — the only code a total across these rows may carry. `currency` is this ROW's, and the two differ whenever the workspace holds profiles that report differently."},"mtd_spend_workspace":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"`mtd_spend` converted into `workspace_currency`, or NULL when no exchange rate existed for the pair. Sum THIS column, never `mtd_spend`, and exclude the NULLs rather than falling back to the unconverted number."},"monthly_budget_cents_workspace":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"In CENTS. The budget converted into `workspace_currency`. NULL when no budget is set (`monthly_budget_cents` is NULL too) or when no rate existed (it is not). Converted from `currency`, which `monthly_budget_currency` may override, so this and `mtd_spend_workspace` do not necessarily share a rate."}},"required":["profile_id","profile_name","currency","mtd_spend","mtd_outcomes","monthly_budget_cents","monthly_outcome_goal","days_in_month","days_elapsed","pace_spend_pct","pace_outcomes_pct","daily_spend","workspace_currency","mtd_spend_workspace","monthly_budget_cents_workspace"]},"WorkspaceRecommendation":{"type":"object","properties":{"id":{"type":"string"},"profile_id":{"type":"string"},"kind":{"type":"string"},"risk_tier":{"type":"string"},"status":{"type":"string"},"rationale":{"type":"object","properties":{"source":{"type":"string"},"text":{"type":["string","null"]},"evidence":{"type":"object","additionalProperties":{}}},"required":["source","text","evidence"]},"suggested_modifications":{"type":"object","additionalProperties":{}},"target":{"type":"object","properties":{"ad_id":{"type":["string","null"]},"adset_id":{"type":["string","null"]},"external_action_url":{"type":["string","null"]}},"required":["ad_id","adset_id","external_action_url"]},"expected_impact_dollars":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"A FORECAST made when the recommendation was written."},"confidence":{"type":"number"},"rec_rank_score":{"type":"number"},"score_window":{"type":"string"},"snapshot_date":{"type":"string"},"expires_at":{"type":["string","null"]},"applied_at":{"type":["string","null"]},"verified_at":{"type":["string","null"]},"dismissed_at":{"type":["string","null"]},"created_at":{"type":"string"},"realized_impact_dollars":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"What the apply actually produced. NULL until measured — which is not zero, and a surface that shows only `expected_impact_dollars` is reporting a hope as a result."},"calibration_error":{"type":["number","null"],"description":"`realized - expected`, in the same units. NULL until measured."},"calibration_eval_window_end":{"type":["string","null"]},"data_quality_tier":{"type":["string","null"],"description":"What evidence the recommendation rests on — `cash` / `outcomes` / `engagement` / `meta_only` / `none`. The same ladder `truth_grade` walks for a leaderboard row."}},"required":["id","profile_id","kind","risk_tier","status","rationale","suggested_modifications","target","expected_impact_dollars","confidence","rec_rank_score","score_window","snapshot_date","expires_at","applied_at","verified_at","dismissed_at","created_at","realized_impact_dollars","calibration_error","calibration_eval_window_end","data_quality_tier"]},"WorkspaceReplicateRun":{"type":"object","properties":{"id":{"type":"string"},"workspace_id":{"type":"string"},"source_profile_id":{"type":"string"},"target_profile_id":{"type":"string"},"source_ad_external_id":{"type":"string"},"source_ad_name":{"type":["string","null"]},"status":{"type":"string","example":"review"},"current_phase":{"type":"string"},"error_code":{"type":["string","null"]},"error_phase":{"type":["string","null"]},"fit":{"type":["object","null"],"additionalProperties":{}},"brief":{"type":["object","null"],"additionalProperties":{}},"variants":{"type":["array","null"],"items":{}},"scene_rail":{"type":["array","null"],"items":{}},"expected_band":{"type":["object","null"],"additionalProperties":{}},"brand_guard":{"type":["object","null"],"additionalProperties":{}},"hook_candidates":{"type":["array","null"],"items":{}},"extract_payload":{"type":["object","null"],"additionalProperties":{}},"research_payload":{"type":["object","null"],"additionalProperties":{}},"input_tokens":{"type":"integer"},"output_tokens":{"type":"integer"},"total_cost_usd":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"stream_id":{"type":["string","null"]},"stream_status":{"type":"string"},"stream_started_at":{"type":["string","null"]},"ads_lab_session_id":{"type":["string","null"],"description":"Set once the run has been handed to Ads Lab; the handoff is idempotent on it."},"created_by":{"type":"string"},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","workspace_id","source_profile_id","target_profile_id","source_ad_external_id","source_ad_name","status","current_phase","error_code","error_phase","fit","brief","variants","scene_rail","expected_band","brand_guard","hook_candidates","extract_payload","research_payload","input_tokens","output_tokens","total_cost_usd","stream_id","stream_status","stream_started_at","ads_lab_session_id","created_by","created_at","updated_at"]},"WorkspaceReplicateHandoff":{"type":"object","properties":{"run_id":{"type":"string"},"status":{"type":"string","description":"The status now on the run — `sent` after a handoff lands."},"ads_lab_session_id":{"type":"string","description":"The session the run is bound to. On a repeat handoff this is the FIRST one, not the id you just sent — see `already_handed_off`."},"already_handed_off":{"type":"boolean","description":"TRUE when the run already carried a session and this call changed nothing. Your `ads_lab_session_id` was NOT stored; open the one in this response instead."}},"required":["run_id","status","ads_lab_session_id","already_handed_off"]},"CreativeTestDecision":{"type":"object","properties":{"test_key":{"type":"string"},"profile_id":{"type":"string"},"score_window":{"type":"string"},"status":{"type":"string","description":"What is now recorded — on a re-decide, the NEW value."}},"required":["test_key","profile_id","score_window","status"]},"AdExperimentPromotion":{"type":"object","properties":{"promotion_id":{"type":"string","description":"Stable for `(workspace, profile, ad)`. Re-posting returns the SAME id rather than minting a second promotion."},"profile_id":{"type":"string"},"ad_external_id":{"type":"string"},"status":{"type":"string","description":"The status now on the row — NOT necessarily the one you asked for. See `advanced_beyond_write`."},"created":{"type":"boolean"},"advanced_beyond_write":{"type":"boolean","description":"TRUE when the row had already advanced past the status you wrote (the reconciliation sync moved it to `live` or `linked`), so YOUR DRAFT WAS NOT STORED and the existing promotion was left untouched. A client that ignores this shows an operator a draft that was never saved."},"create_job_enqueued":{"type":"boolean","description":"Whether a direct-create job was queued. Gated by a server flag and by the profile having a Meta ad account; the enqueue is non-fatal, so `false` here does not mean the promotion failed."}},"required":["promotion_id","profile_id","ad_external_id","status","created","advanced_beyond_write","create_job_enqueued"]},"MeUser":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"email":{"type":["string","null"],"example":"ana@clinica.cl"},"full_name":{"type":["string","null"]},"avatar_url":{"type":["string","null"]},"locale":{"type":["string","null"],"description":"`en` | `es`, or null when the user never chose one."},"pending_email":{"type":["string","null"],"description":"An email change awaiting confirmation, or null. Reported because a pending change is otherwise INVISIBLE — `email` still shows the old address. Starting or cancelling one is not on this API: it is a Supabase Auth flow with confirmation mail to both addresses."}},"required":["id","email","full_name","avatar_url","locale","pending_email"]},"MeIdentity":{"type":"object","properties":{"identity_id":{"type":"string","example":"uuid"},"provider":{"type":"string","example":"google","description":"The provider key (`email`, `google`, …), never a display name."},"email":{"type":["string","null"],"description":"The address THAT PROVIDER knows the user by — not necessarily the account email. A linked Google account with a different address is a normal state."},"created_at":{"type":["string","null"]}},"required":["identity_id","provider","email","created_at"]},"MeSession":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"is_current":{"type":"boolean","description":"Whether this is the session the request was made with. Resolved from the `session_id` claim on the VERIFIED bearer token, so a client does not have to decode its own JWT to know which row not to offer a revoke on."},"created_at":{"type":["string","null"]},"last_active_at":{"type":["string","null"],"description":"The freshest of `refreshed_at` / `updated_at` / `created_at`, as an explicit UTC instant. Supabase stores the first of those without a time zone; it is stamped here so the value does not mean something different per reader."},"user_agent":{"type":["string","null"],"description":"The raw UA string. Turning it into 'Mac · Chrome' is presentation."},"ip":{"type":["string","null"]},"aal":{"type":["string","null"],"example":"aal1","description":"`aal1` | `aal2` — the assurance level this session reached."}},"required":["id","is_current","created_at","last_active_at","user_agent","ip","aal"]},"MeWorkspaceRef":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"name":{"type":"string","example":"Aristeia"}},"required":["id","name"]},"Me":{"type":"object","properties":{"user":{"$ref":"#/components/schemas/MeUser"},"identities":{"type":"array","items":{"$ref":"#/components/schemas/MeIdentity"}},"sessions":{"type":"array","items":{"$ref":"#/components/schemas/MeSession"}},"sole_owner_workspaces":{"type":"array","items":{"$ref":"#/components/schemas/MeWorkspaceRef"},"description":"Workspaces this user is the ONLY active owner of — the blockers on `DELETE /api/v1/me`. An empty array means nothing stands in the way. Enforced there, not merely advisory: `DELETE /api/v1/me` re-checks this same list server-side before deleting anything."}},"required":["user","identities","sessions","sole_owner_workspaces"]},"MeApp":{"type":"object","properties":{"app":{"type":"object","properties":{"client_id":{"type":"string","description":"The OAuth app's public client id."},"name":{"type":"string","description":"The OAuth app's display name."}},"required":["client_id","name"]},"return_url_supported":{"type":"boolean","description":"True exactly when the app's EFFECTIVE `allowed_return_origins` is non-empty — the explicit column when set, otherwise derived from the origins of `redirect_uris`. This is the precondition for a delegated connect start to honour a `return_url`. Check it once instead of learning the answer from the first `origin_not_allowed` 400."}},"required":["app","return_url_supported"]},"MeDeleteResult":{"type":"object","properties":{"deleted":{"type":"boolean"},"blockers":{"type":"array","items":{"$ref":"#/components/schemas/MeWorkspaceRef"},"description":"Empty when `deleted` is true. When `deleted` is false, the caller's `sole_owner_workspaces` — transfer ownership of each, then retry."}},"required":["deleted","blockers"]},"Ok":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]},"McpToken":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"name":{"type":"string"},"key_prefix":{"type":"string","example":"atb_user_1a2b","description":"The first few hex chars of the secret. Never the secret itself."},"scopes":{"type":"array","items":{"type":"string"},"example":["mcp:read"]},"status":{"type":"string","example":"active"},"rate_limit_units_per_minute":{"type":"number"},"expires_at":{"type":["string","null"]},"last_used_at":{"type":["string","null"]},"total_units_used":{"type":"number"},"created_at":{"type":"string"}},"required":["id","name","key_prefix","scopes","status","rate_limit_units_per_minute","expires_at","last_used_at","total_units_used","created_at"]},"McpTokenCreated":{"allOf":[{"$ref":"#/components/schemas/McpToken"},{"type":"object","properties":{"token":{"type":"string","example":"atb_user_...","description":"The live secret. Shown exactly ONCE — this response is the only time it is ever returned."},"rotation_deadline":{"type":"string","description":"Set only on a rotate — when the OLD token stops working."},"warning":{"type":"string"}},"required":["token","warning"]}]},"McpOauthGrant":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"client_id":{"type":"string"},"client_name":{"type":["string","null"]},"scope":{"type":"string"},"resource":{"type":["string","null"]},"refresh_issued_at":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","client_id","client_name","scope","resource","refresh_issued_at","created_at"]},"McpUsage":{"type":"object","properties":{"period_kind":{"type":"string","enum":["trial","monthly"]},"period_start":{"type":"string"},"units_used":{"type":"number"},"units_cap":{"type":"number"},"tier":{"type":"string"}},"required":["period_kind","period_start","units_used","units_cap","tier"]},"ApiKey":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"name":{"type":"string"},"key_prefix":{"type":"string","example":"atb_live_1a2b","description":"The first few hex chars of the secret. Never the secret itself."},"scopes":{"type":"array","items":{"type":"string"},"example":["analytics:read","campaigns:read"]},"status":{"type":"string","example":"active"},"rate_limit_per_minute":{"type":"number"},"expires_at":{"type":["string","null"]},"last_used_at":{"type":["string","null"]},"total_requests":{"type":"number"},"created_at":{"type":"string"}},"required":["id","name","key_prefix","scopes","status","rate_limit_per_minute","expires_at","last_used_at","total_requests","created_at"]},"ApiKeyCreated":{"allOf":[{"$ref":"#/components/schemas/ApiKey"},{"type":"object","properties":{"key":{"type":"string","example":"atb_live_...","description":"The live secret. Shown exactly ONCE — this response is the only time it is ever returned."},"old_key_expires_at":{"type":"string","description":"Set only on a rotate — when the OLD key stops working."},"warning":{"type":"string"}},"required":["key","warning"]}]},"ApiKeyRevoked":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"status":{"type":"string","example":"revoked"}},"required":["id","status"]},"ProfileUpdate":{"type":"object","properties":{"mode":{"type":"string","enum":["lead_gen","ecommerce","hybrid"]},"default_attribution_model":{"type":"string","example":"last_touch"},"currency":{"type":"string","example":"CLP","description":"ISO 4217."},"onboarding_completed":{"type":"boolean"},"onboarding_skip_reason":{"type":"string","enum":["reviewer_skip"],"description":"Recorded on the `PROFILE_ONBOARDING_COMPLETED` audit row's metadata when set alongside `onboarding_completed: true` — the reviewer-skip path, distinct from a normal finish."},"onboarding_state":{"type":"object","properties":{"version":{"type":"number","enum":[1]},"current_step":{"type":"integer","minimum":0},"business_type":{"type":"string","enum":["lead_gen","ecommerce","hybrid"]},"attribution_model":{"type":"string","minLength":1},"stage_mapping_draft":{"type":"object","properties":{"stages":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1},"event_key":{"type":"string","minLength":1},"source_pipeline_name":{"type":["string","null"]},"mapping_confidence":{"type":["string","null"],"enum":["high","medium","low",null]}},"required":["name","event_key"]}},"outcome_sub_step":{"type":"string","enum":["pick_pipeline","review_mapping"]},"selected_pipeline_keys":{"type":"array","items":{"type":"string"}},"stages_dirty":{"type":"boolean"}},"required":["stages"],"additionalProperties":false},"tracking_substep":{"type":"string","enum":["custom_domain","meta_pixel","install","verify","install_shopify","verify_shopify"]},"updated_at":{"type":"string"}},"required":["version","current_step"],"additionalProperties":false,"description":"#1056 — a WHOLE-OBJECT replace of the onboarding wizard's in-progress draft (never a merge). `version` must be `1`; any key this schema does not name is a 400 naming it. The server always overwrites `updated_at` with its own clock, even if you send one."}}},"OutcomeDefinitionRow":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"definition_type":{"type":"string","enum":["pipeline_stage","order_event"]},"name":{"type":"string","example":"Descualificado"},"event_key":{"type":"string","example":"lead_created"},"sort_order":{"type":"number"},"source_pipeline_name":{"type":["string","null"]}},"required":["id","definition_type","name","event_key","sort_order","source_pipeline_name"]},"OutcomeDefinitionUpdate":{"type":"object","properties":{"event_key":{"type":"string","minLength":1,"example":"lead_created","description":"Which milestone this CRM pipeline-stage label maps to."}},"required":["event_key"]},"OutcomeDefinitionsReplaceRequest":{"type":"object","properties":{"definition_type":{"type":"string","enum":["pipeline_stage","order_event"]},"definitions":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"example":"Descualificado"},"event_key":{"type":"string","minLength":1,"example":"lead_created"},"source_pipeline_name":{"type":["string","null"]}},"required":["name","event_key"]},"description":"The FULL new list for this family, in display order — `sort_order` is assigned from array position. Every existing definition of this `definition_type` on the profile is deleted first, so an empty array clears the family."}},"required":["definition_type","definitions"]},"OutcomeDefinitionsReplaceResult":{"type":"object","properties":{"definition_type":{"type":"string","enum":["pipeline_stage","order_event"]},"definitions":{"type":"array","items":{"$ref":"#/components/schemas/OutcomeDefinitionRow"}}},"required":["definition_type","definitions"]},"ProfileAuditEventCreate":{"type":"object","properties":{"event_type":{"type":"string","enum":["dark_messaging_banner_connect_clicked","dark_messaging_banner_dismissed"]},"metadata":{"type":"object","additionalProperties":{}}},"required":["event_type"]},"ProfileAuditEventResult":{"type":"object","properties":{"event_type":{"type":"string","enum":["dark_messaging_banner_connect_clicked","dark_messaging_banner_dismissed"]},"recorded":{"type":"boolean","enum":[true]}},"required":["event_type","recorded"]},"DarkMessagingSpend":{"type":"object","properties":{"has_instagram_connection":{"type":"boolean","enum":[false]},"conversations_started_30d":{"type":"number"},"observed_ig_conversations":{"type":"number"},"unobserved_conversations":{"type":"number"},"estimated_unattributed_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["has_instagram_connection","conversations_started_30d","observed_ig_conversations","unobserved_conversations","estimated_unattributed_revenue"]},"McpSettings":{"type":"object","properties":{"pii_mode":{"type":"string","enum":["masked_default","full_default"],"description":"Default PII handling for MCP tool reads. `masked_default` (recommended): email and phone are masked unless a request opts in. `full_default`: tokens with `mcp:read_pii` receive raw values automatically. The `mcp:read_pii` scope and `include_sensitive=true` flag always override this default either way."},"mcp_writeback_enabled":{"type":"boolean","description":"Whether admins with mcp:write tokens may run send_meta_conversions."}},"required":["pii_mode","mcp_writeback_enabled"]},"McpSettingsUpdate":{"type":"object","properties":{"pii_mode":{"type":"string","enum":["masked_default","full_default"]},"mcp_writeback_enabled":{"type":"boolean"}}},"McpWritebackAudit":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"user_id":{"type":["string","null"]},"tool_name":{"type":"string","example":"send_meta_conversions"},"mode":{"type":"string","example":"preview"},"result":{"type":"string","example":"success"},"event_count":{"type":"number"},"window_start":{"type":["string","null"]},"window_end":{"type":["string","null"]},"idempotency_key":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","user_id","tool_name","mode","result","event_count","window_start","window_end","idempotency_key","created_at"]},"ConversionSyncActionLogEntry":{"type":"object","properties":{"id":{"type":"string"},"action_kind":{"type":"string"},"tier":{"type":"string"},"status":{"type":"string"},"source":{"type":"string"},"summary":{"type":["string","null"]},"target":{},"error_code":{"type":["string","null"]},"rollback_of":{"type":["string","null"]},"created_at":{"type":"string"},"applied_at":{"type":["string","null"]},"rolled_back_at":{"type":["string","null"]}},"required":["id","action_kind","tier","status","source","summary","error_code","rollback_of","created_at","applied_at","rolled_back_at"]},"ConversionSyncAuditLogEntry":{"type":"object","properties":{"id":{"type":"string"},"actor_id":{"type":["string","null"]},"entity_type":{"type":"string"},"entity_id":{"type":["string","null"]},"action":{"type":"string"},"summary":{"type":["string","null"]},"metadata":{},"created_at":{"type":"string"}},"required":["id","actor_id","entity_type","entity_id","action","summary","created_at"]},"ConversionSyncDefinitionResponse":{"type":"object","properties":{"ctwa":{"type":"object","properties":{"ready":{"type":"boolean"},"ctwaConversations":{"type":"number"},"whatsappConnected":{"type":"boolean"}},"required":["ready","ctwaConversations","whatsappConnected"]},"providers":{"type":"object","properties":{"stripe":{"type":"boolean"},"gohighlevel":{"type":"boolean"},"mercadopago":{"type":"boolean"}},"required":["stripe","gohighlevel","mercadopago"]},"options":{"type":"array","items":{"$ref":"#/components/schemas/ConversionSyncDefinitionOption"}},"current":{"type":["object","null"],"properties":{"kind":{"type":"string","enum":["milestone","tag","cash"]},"choiceId":{"type":"string"},"ruleId":{"type":"string"},"eventName":{"type":"string"}},"required":["kind","choiceId","ruleId","eventName"]},"exampleCash":{"type":["object","null"],"properties":{"amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"currency":{"type":"string"}},"required":["amount","currency"]}},"required":["ctwa","providers","options","current","exampleCash"]},"ConversionSyncDefinitionOption":{"type":"object","properties":{"kind":{"type":"string","enum":["milestone","tag","cash"]},"available":{"type":"boolean"},"reason":{"type":["string","null"],"enum":["no_crm_pipeline","no_engagement_events","no_cash_definition",null]},"choices":{"type":"array","items":{"$ref":"#/components/schemas/ConversionSyncDefinitionChoice"}}},"required":["kind","available","reason","choices"]},"ConversionSyncDefinitionChoice":{"type":"object","properties":{"id":{"type":"string"},"kind":{"type":"string","enum":["milestone","tag","cash"]},"label":{"type":"string"},"sublabel":{"type":["string","null"]},"eventName":{"type":"string"},"volume7d":{"type":["number","null"]},"sourceKind":{"type":"string","enum":["conversion_definition","pipeline_stage_transition"]},"sourceRefId":{"type":["string","null"]},"sourceConfig":{"type":["object","null"],"additionalProperties":{}},"valueMode":{"type":"string","enum":["event_value","none"]},"existingRuleId":{"type":["string","null"]}},"required":["id","kind","label","sublabel","eventName","volume7d","sourceKind","sourceRefId","sourceConfig","valueMode","existingRuleId"]},"ConversionSyncLegalState":{"type":"object","properties":{"compliance":{"type":["object","null"],"additionalProperties":{}},"documents":{"type":"array","items":{"$ref":"#/components/schemas/ConversionSyncLegalDocument"}}},"required":["compliance","documents"]},"ConversionSyncLegalDocument":{"type":"object","properties":{"id":{"type":"string"},"workspace_id":{"type":"string"},"profile_id":{"type":"string"},"compliance_id":{"type":["string","null"]},"document_type":{"type":"string"},"document_name":{"type":"string"},"storage_path":{"type":["string","null"]},"storage_bucket":{"type":["string","null"]},"external_url":{"type":["string","null"]},"uploaded_by":{"type":["string","null"]},"metadata":{},"created_at":{"type":"string"},"updated_at":{"type":"string"},"open_url":{"type":["string","null"]}},"required":["id","workspace_id","profile_id","compliance_id","document_type","document_name","storage_path","storage_bucket","external_url","uploaded_by","created_at","updated_at","open_url"]},"ConversionSyncAudiencesResponse":{"type":"object","properties":{"audiences":{"type":"array","items":{"$ref":"#/components/schemas/ConversionSyncAudience"}},"actions":{"type":"array","items":{"$ref":"#/components/schemas/ConversionSyncAudienceAction"}}},"required":["audiences","actions"]},"ConversionSyncAudience":{"type":"object","properties":{"id":{"type":"string"},"kind":{"type":"string"},"status":{"type":"string"},"name":{"type":"string"},"value_mode":{"type":["string","null"]},"seed_mode":{"type":["string","null"]},"meta_audience_id":{"type":["string","null"]},"parent_audience_id":{"type":["string","null"]},"lookalike_spec":{},"member_count":{"type":["number","null"]},"seed_stats":{},"last_synced_at":{"type":["string","null"]},"last_users_replace_at":{"type":["string","null"]},"approved_at":{"type":["string","null"]},"error_code":{"type":["string","null"]},"error_message":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","kind","status","name","value_mode","seed_mode","meta_audience_id","parent_audience_id","member_count","last_synced_at","last_users_replace_at","approved_at","error_code","error_message","created_at"]},"ConversionSyncAudienceAction":{"type":"object","properties":{"id":{"type":"string"},"action_kind":{"type":"string"},"status":{"type":"string"},"source":{"type":"string"},"request_payload":{},"error_code":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","action_kind","status","source","error_code","created_at"]},"ConversionSyncAudienceSeedPreview":{"type":"object","properties":{"mode":{"type":"string","enum":["value_based","lead_fallback","insufficient"]},"valueMode":{"type":"string"},"memberCount":{"type":"number"},"totalMembers":{"type":"number"},"skippedNoIdentifiers":{"type":"number"},"dominantCountry":{"type":"string"},"currency":{"type":"string"},"medianValue":{"type":["number","null"]},"totalSeedValue":{"type":"number"},"refundsSubtracted":{"type":"number"},"cashCustomers":{"type":"number"},"leadCustomers":{"type":"number"},"members":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"lastPaymentAt":{"type":["string","null"]}},"required":["name","value","lastPaymentAt"]}},"remainingCount":{"type":"number"}},"required":["mode","valueMode","memberCount","totalMembers","skippedNoIdentifiers","dominantCountry","currency","medianValue","totalSeedValue","refundsSubtracted","cashCustomers","leadCustomers","members","remainingCount"]},"ConversionSyncSetupLinkResponse":{"type":"object","properties":{"link":{"type":["object","null"],"properties":{"id":{"type":"string"},"url":{"type":"string"},"createdAt":{"type":"string"},"openedAt":{"type":["string","null"]},"connectedAt":{"type":["string","null"]},"completedAt":{"type":["string","null"]},"fixRequestedAt":{"type":["string","null"]},"status":{"type":"string","enum":["pending","opened","connected","provisioning","choice_required","action_required","ready","revoked","expired"]},"expiresAt":{"type":"string"},"agencyName":{"type":"string"},"inviterName":{"type":["string","null"]},"clientName":{"type":"string"},"metaUserName":{"type":["string","null"]},"missingPermissions":{"type":"array","items":{"type":"string"}},"stages":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","enum":["connect","ad_account","business","dataset","wire"]},"status":{"type":"string","enum":["pending","active","done","skipped","failed"]},"detail":{"type":"string"}},"required":["key","status"]}},"adAccounts":{"type":["array","null"],"items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"currency":{"type":["string","null"]}},"required":["id","name","currency"]}},"resultSummary":{"type":["object","null"],"properties":{"business":{"type":"string","enum":["existing","created","skipped"]},"businessName":{"type":"string"},"datasetId":{"type":"string"},"pixelCreated":{"type":"boolean"},"adAccountId":{"type":"string"},"adAccountName":{"type":"string"},"currency":{"type":["string","null"]}},"required":["business","datasetId","pixelCreated","adAccountId","adAccountName","currency"]},"lastError":{"type":["string","null"]}},"required":["id","url","createdAt","openedAt","connectedAt","completedAt","fixRequestedAt","status","expiresAt","agencyName","inviterName","clientName","metaUserName","missingPermissions","stages","adAccounts","resultSummary","lastError"]}},"required":["link"]},"ConversionSyncTransparencyResponse":{"type":"object","properties":{"currentMode":{"type":"string"},"eventTimeMs":{"type":"number"},"modes":{"type":"object","properties":{"platform_safe":{"type":"object","properties":{"mode":{"type":"string","enum":["standard","hipaa","custom","platform_safe"]},"fields":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"group":{"type":"string","enum":["identifiers","outcome","health"]},"status":{"type":"string","enum":["kept","stripped","sanitized","sent"]},"before":{"type":"string"},"after":{"type":["string","null"]},"hashed":{"type":"boolean"},"anonymous":{"type":"boolean"},"reasonToken":{"type":["string","null"]}},"required":["id","group","status","before","after","hashed","anonymous","reasonToken"]}},"counts":{"type":"object","properties":{"sent":{"type":"number"},"stripped":{"type":"number"}},"required":["sent","stripped"]}},"required":["mode","fields","counts"]},"standard":{"type":"object","properties":{"mode":{"type":"string","enum":["standard","hipaa","custom","platform_safe"]},"fields":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"group":{"type":"string","enum":["identifiers","outcome","health"]},"status":{"type":"string","enum":["kept","stripped","sanitized","sent"]},"before":{"type":"string"},"after":{"type":["string","null"]},"hashed":{"type":"boolean"},"anonymous":{"type":"boolean"},"reasonToken":{"type":["string","null"]}},"required":["id","group","status","before","after","hashed","anonymous","reasonToken"]}},"counts":{"type":"object","properties":{"sent":{"type":"number"},"stripped":{"type":"number"}},"required":["sent","stripped"]}},"required":["mode","fields","counts"]}},"required":["platform_safe","standard"]}},"required":["currentMode","eventTimeMs","modes"]},"ConversionSyncFeedResponse":{"type":"object","properties":{"summary":{"type":"object","properties":{"total":{"type":"number"},"byStatus":{"type":"object","additionalProperties":{"type":"number"}},"byEligibility":{"type":"object","additionalProperties":{"type":"number"}}},"required":["total","byStatus","byEligibility"]},"globalSummary":{"type":"object","properties":{"total":{"type":"number"},"byStatus":{"type":"object","additionalProperties":{"type":"number"}},"byStatusEvents":{"type":"object","additionalProperties":{"type":"number"}},"totalEvents":{"type":"number"}},"required":["total","byStatus","byStatusEvents","totalEvents"]},"hasMore":{"type":"boolean"},"nextCursor":{"type":["string","null"]},"eventNames":{"type":"array","items":{"type":"string"}},"rows":{"type":"array","items":{"$ref":"#/components/schemas/ConversionSyncFeedRow"}}},"required":["summary","globalSummary","hasMore","nextCursor","eventNames","rows"]},"ConversionSyncFeedRow":{"type":"object","additionalProperties":{},"description":"A conversion_exports row, enriched with the credited touch, customer context and per-attempt delivery log — the same shape the internal dashboard's Deliveries feed renders. Deliberately open: the row carries the export's raw + redacted payload snapshots, which are large and change shape by destination."},"ProfileBranding":{"type":"object","properties":{"accentColor":{"type":"string"},"hideAtribuFooter":{"type":"boolean"},"useWorkspaceBranding":{"type":"boolean"},"logoStoragePath":{"type":"string"},"logoUpdatedAt":{"type":"string"},"logoContentType":{"type":"string"}}},"ProfileBrandingOverview":{"type":"object","properties":{"branding":{"$ref":"#/components/schemas/ProfileBranding"},"logo_url":{"type":["string","null"]},"can_hide_footer":{"type":"boolean"},"plan_id":{"type":["string","null"]}},"required":["branding","logo_url","can_hide_footer","plan_id"]},"ProfilePacing":{"type":"object","properties":{"monthly_budget_cents":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"monthly_outcome_goal":{"type":["integer","null"]},"monthly_budget_currency":{"type":["string","null"],"example":"CLP"},"profile_currency":{"type":"string","example":"USD"},"mode":{"type":["string","null"],"example":"lead_gen"}},"required":["monthly_budget_cents","monthly_outcome_goal","monthly_budget_currency","profile_currency","mode"]},"BioPageConfig":{"type":"object","properties":{"headline":{"type":["string","null"],"maxLength":120},"subheadline":{"type":["string","null"],"maxLength":280},"accent_color":{"type":["string","null"],"example":"#112233"},"logo_url":{"type":["string","null"],"maxLength":2000},"link_position":{"type":"string","enum":["prefix","suffix"]}}},"ProfileBioPage":{"type":"object","properties":{"slug":{"type":["string","null"],"example":"acme-motors"},"config":{"$ref":"#/components/schemas/BioPageConfig"}},"required":["slug","config"]},"BioSlugAvailability":{"type":"object","properties":{"available":{"type":"boolean"},"slug":{"type":"string","description":"The NORMALISED candidate (trimmed, lowercased)."},"reason":{"type":["string","null"],"description":"Why it is unavailable — or, when `available` is true and the caller already owns the handle, that fact. Null when it is simply free."}},"required":["available","slug","reason"]},"ProfileShareDestination":{"type":"object","properties":{"destination_url":{"type":["string","null"]}},"required":["destination_url"]},"ProfileShareDestinationWrite":{"type":"object","properties":{"destination_url":{"type":["string","null"]},"backfilled":{"type":"integer","description":"Pre-existing `ig_short_links` rows whose NULL destination this write filled in. Always 0 when clearing the default — a stored destination is never overwritten with NULL."}},"required":["destination_url","backfilled"]},"ProfileRecomputeFx":{"type":"object","properties":{"reset":{"type":"integer","description":"Conversions whose FX normalisation was reset to `pending`."},"reporting_currency":{"type":"string","example":"CLP"},"recompute_enqueued":{"type":"boolean","description":"False when the pgmq enqueue failed. The reset is durable either way and the hourly recompute-staleness sweep is the backstop."}},"required":["reset","reporting_currency","recompute_enqueued"]},"CustomerSearchCandidate":{"type":"object","properties":{"customer_profile_id":{"type":"string","format":"uuid"},"first_name":{"type":["string","null"]},"last_name":{"type":["string","null"]},"primary_email":{"type":["string","null"]},"primary_phone":{"type":["string","null"]},"first_seen_at":{"type":["string","null"]},"last_seen_at":{"type":["string","null"]},"conversion_count":{"type":"integer"},"cash_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"sources":{"type":"array","items":{"type":"string"},"description":"Where this customer was seen — `stripe`, `gohighlevel`, `tracker`, …"}},"required":["customer_profile_id","first_name","last_name","primary_email","primary_phone","first_seen_at","last_seen_at","conversion_count","cash_revenue","sources"],"description":"One merge candidate, exactly the row `rpc_customer_search` returns."},"CustomerJourneySummary":{"type":"object","properties":{"summary":{"type":["string","null"]},"event_count":{"type":["integer","null"]},"cached":{"type":"boolean","description":"True when the answer came from the stored summary rather than a model call."}},"required":["summary","event_count","cached"]},"ProfileInvitation":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"profile_role":{"type":"string","enum":["manager","viewer"]},"permissions":{"type":"array","items":{"type":"string"}},"invited_by":{"type":["string","null"],"format":"uuid"},"expires_at":{"type":"string"},"created_at":{"type":"string"}},"required":["id","email","profile_role","permissions","invited_by","expires_at","created_at"]},"ProfileMembership":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"profile_role":{"type":"string","enum":["manager","viewer"]},"permissions":{"type":"array","items":{"type":"string"}},"status":{"type":"string","example":"active"}},"required":["id","user_id","profile_role","permissions","status"]},"WorkspaceBranding":{"type":"object","properties":{"accentColor":{"type":"string"},"hideAtribuFooter":{"type":"boolean"},"emailFromName":{"type":"string"},"emailReplyTo":{"type":"string"},"logoStoragePath":{"type":"string"},"logoUpdatedAt":{"type":"string"},"logoContentType":{"type":"string"}}},"WorkspaceBrandingOverview":{"type":"object","properties":{"branding":{"$ref":"#/components/schemas/WorkspaceBranding"},"logo_url":{"type":["string","null"]},"can_hide_footer":{"type":"boolean"},"plan_id":{"type":["string","null"]}},"required":["branding","logo_url","can_hide_footer","plan_id"]},"WorkspaceInvitation":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"workspace_role":{"type":"string"},"invited_by":{"type":"string","format":"uuid"},"expires_at":{"type":"string"},"created_at":{"type":"string"}},"required":["id","email","workspace_role","invited_by","expires_at","created_at"]},"WorkspaceSenderDomain":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"domain":{"type":"string"},"status":{"type":"string","enum":["pending","verified","failed","temporary_failure"]},"dns_records":{"type":"array","items":{"type":"object","additionalProperties":{}}},"sender_address":{"type":"string"},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","domain","status","dns_records","sender_address","created_at","updated_at"]},"WorkspaceSnapshotSummary":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"title":{"type":["string","null"]},"score_window":{"type":"string","enum":["7d","14d","28d","lifetime"]},"created_at":{"type":"string"},"expires_at":{"type":"string"},"revoked_at":{"type":["string","null"]},"public_url":{"type":"string"}},"required":["id","title","score_window","created_at","expires_at","revoked_at","public_url"]},"WorkspaceSnapshotCreated":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"token":{"type":"string"},"expires_at":{"type":"string"},"public_url":{"type":"string"},"pdf_url":{"type":"string"}},"required":["id","token","expires_at","public_url","pdf_url"]},"WorkspaceConnectedApp":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"app_id":{"type":"string","format":"uuid"},"app_name":{"type":"string"},"app_logo_url":{"type":["string","null"]},"profile_id":{"type":"string","format":"uuid"},"profile_name":{"type":"string"},"kind":{"type":"string","enum":["connection","profile"]},"data_connection_id":{"type":["string","null"],"format":"uuid"},"provider":{"type":["string","null"],"enum":["whatsapp","instagram",null]},"external_account_name":{"type":["string","null"]},"scopes":{"type":"array","items":{"type":"string"}},"created_at":{"type":"string"}},"required":["id","app_id","app_name","app_logo_url","profile_id","profile_name","kind","data_connection_id","provider","external_account_name","scopes","created_at"]},"WorkspaceComplianceOverview":{"type":"object","properties":{"is_healthcare_agency":{"type":"boolean"},"profiles":{"type":"array","items":{"type":"object","properties":{"profile_id":{"type":"string","format":"uuid"},"profile_name":{"type":"string"},"privacy_mode":{"type":"string"},"baa_status":{"type":"string"},"hipaa_eligibility_status":{"type":"string"},"signature_status":{"type":["string","null"]},"signed_at":{"type":["string","null"]},"active_export_count":{"type":"integer"}},"required":["profile_id","profile_name","privacy_mode","baa_status","hipaa_eligibility_status","signature_status","signed_at","active_export_count"]}}},"required":["is_healthcare_agency","profiles"]},"ReadinessNext":{"anyOf":[{"type":"object","properties":{"method":{"type":"string","enum":["GET","POST","PUT","PATCH","DELETE"]},"path":{"type":"string","example":"/api/v1/goals/definitions"}},"required":["method","path"]},{"type":"object","properties":{"handoff_kind":{"type":"string","enum":["connect","sign_dpa","checkout","contact_support"]}},"required":["handoff_kind"]}],"description":"The one next action for this step, in one of two shapes.\n\n`{method, path}` is a call you can make right now with the credential you already hold, and the path is always one this API serves TODAY — never a route that is being built. Where a step's own write does not exist on v1 yet, the nearest existing route is named and `why` says where the write actually happens.\n\n`{handoff_kind}` is a class of action only a PERSON can take: `connect` (an OAuth consent — it needs a browser), `sign_dpa` (a signature), `checkout` (a payment), `contact_support` (Atribu itself has to change something). It tells you WHAT kind of hand-off is owed, not where: minting the actual URL is a separate capability.\n\n`null` only when `status` is `done`. Every other status carries one."},"ReadinessStep":{"type":"object","properties":{"key":{"type":"string","enum":["attribution_enabled","tracker_installed","ad_platform_connected","crm_or_outcome_source_connected","payments_connected","conversion_definitions_valid","attribution_windows_set","first_conversion_attributed","capi_destination_enabled","capi_rules_enabled","legal_dpa","plan","whatsapp_dataset_connected"],"description":"Which step this is. The `steps` array is always ALL of them, in golden-path order."},"status":{"type":"string","enum":["done","missing","degraded","blocked"],"description":"`done` — satisfied. `missing` — never configured; the ordinary next thing to do. `degraded` — configured and not working (a reconnect-required ad account), so the fix is to RE-connect rather than to connect. `blocked` — cannot be satisfied by you at all; today only `attribution_enabled` on a partner-provisioned profile.\n\nOnly `done` counts toward `summary.done`. `degraded` is not partial credit: a destination that is configured and failing exports nothing."},"why":{"type":"string","example":"meta_ads is connected but its status is \"reconnect_required\" — it is not delivering spend or delivery data.","description":"One plain-English sentence saying why the step is in this state — which provider is degraded, whether the attribution windows are defaults or a choice, where a write happens when this API has no route for it yet. Written to be read and relayed, so it names concrete routes and providers. Not localized: render Spanish off `key` and `status`, which are stable, rather than off this sentence, which is not."},"next":{"$ref":"#/components/schemas/ReadinessNext"},"docs_url":{"type":"string","example":"https://www.atribu.app/docs/getting-started/connect-ads","description":"The documentation page that explains this step."}},"required":["key","status","why","next","docs_url"]},"ReadinessSummary":{"type":"object","properties":{"done":{"type":"integer","example":7,"description":"How many steps are `done`. `degraded` steps are not counted."},"total":{"type":"integer","example":13,"description":"How many steps there are. Published so you need not hardcode it."},"next_step":{"type":["string","null"],"enum":["attribution_enabled","tracker_installed","ad_platform_connected","crm_or_outcome_source_connected","payments_connected","conversion_definitions_valid","attribution_windows_set","first_conversion_attributed","capi_destination_enabled","capi_rules_enabled","legal_dpa","plan","whatsapp_dataset_connected",null],"description":"The FIRST non-`done` step in golden-path order, or null when everything is done. First, not 'most important': the order IS the priority."}},"required":["done","total","next_step"]},"ProfileReadiness":{"type":"object","properties":{"steps":{"type":"array","items":{"$ref":"#/components/schemas/ReadinessStep"},"description":"Every step, always, in golden-path order. A checklist that omitted the steps it could not evaluate would have a length that varies by profile, and `done`/`total` would stop meaning anything."},"summary":{"$ref":"#/components/schemas/ReadinessSummary"},"wizard_finished":{"type":"boolean","description":"`onboarding_completed` on the profile — REPORTED, NOT TRUSTED. It means one thing: a person pressed Finish in the Atribu console's setup wizard. Every wizard step is skippable, so it is not evidence that any step above is done, and nothing in `summary` is derived from it. Use it only to tell 'has been through onboarding and is still missing things' from 'nobody has started'."}},"required":["steps","summary","wizard_finished"]},"AttributionSettings":{"type":"object","properties":{"click_window_days":{"type":"integer","minimum":1,"maximum":365,"example":30,"description":"How many days after a CLICK a conversion still counts as attributed to it."},"view_window_hours":{"type":"integer","minimum":1,"maximum":168,"example":24,"description":"How many hours after a VIEW (no click) a conversion still counts as attributed to it."},"first_touch_window_days":{"type":"integer","minimum":1,"maximum":365,"example":90,"description":"How far back to look for a profile's FIRST touch on a conversion's path."},"cash_window_days":{"type":["integer","null"],"minimum":1,"maximum":730,"example":null,"description":"Overrides the first-touch window for CASH conversions only — high-ticket sales that close long after the ad click. `null` (the default) means cash conversions use the normal windows above; leads and appointments always do, regardless of this setting."}},"required":["click_window_days","view_window_hours","first_touch_window_days","cash_window_days"]},"AttributionSettingsUpdate":{"type":"object","properties":{"click_window_days":{"type":"integer","minimum":1,"maximum":365},"view_window_hours":{"type":"integer","minimum":1,"maximum":168},"first_touch_window_days":{"type":"integer","minimum":1,"maximum":365},"cash_window_days":{"type":["integer","null"],"minimum":1,"maximum":730}},"description":"A PARTIAL update — an omitted key leaves that window untouched. `cash_window_days: null` clears the override; omitting it leaves the current value alone."},"AttributionSettingsUpdateResult":{"allOf":[{"$ref":"#/components/schemas/AttributionSettings"},{"type":"object","properties":{"replay_queued":{"type":"boolean","description":"Whether a full-profile attribution replay was queued. A window redefines which touches attach to which conversion retroactively, so a save that could not queue one leaves already-reported numbers computed under the OLD window until the next replay."},"replay_error":{"type":["string","null"],"description":"Why the replay could not be queued, when replay_queued is false."}},"required":["replay_queued","replay_error"]}]},"CreateDemoProfileRequest":{"type":"object","properties":{"timezone":{"type":"string","minLength":1,"maxLength":64,"example":"America/Santiago","description":"IANA zone for the demo profile. Defaults to the workspace's `default_timezone`, then UTC. Day boundaries in every read are computed in it."},"currency":{"type":"string","pattern":"^[A-Za-z]{3}$","example":"USD","description":"ISO-4217. Every seeded amount — ad spend, payments, the one refund — is denominated in it. Defaults to USD."}}},"DemoProfile":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"name":{"type":"string","example":"Demo — Acme Agency"},"workspace_id":{"type":"string","example":"uuid"},"currency":{"type":"string","example":"USD"},"is_demo":{"type":"boolean","enum":[true]}},"required":["id","name","workspace_id","currency","is_demo"]},"DemoSeedCounts":{"type":"object","properties":{"touches":{"type":"integer","description":"Marketing visits written to `touches` — paid Meta clicks plus organic/direct."},"outcome_events":{"type":"integer","description":"`outcome_events` rows written through the same CRM-ingest primitive every real provider uses: lead_created → appointment_booked → payment_received, plus one refund."},"conversions":{"type":"integer","description":"How many conversions the recompute this call enqueued WILL produce. It is the dataset's promise, not a count already in the table: the engine runs asynchronously, so poll `GET /api/v1/overview` until it matches."},"spend_days":{"type":"integer","description":"Distinct days of ad spend seeded, so ROAS has a denominator."},"definitions":{"type":"integer","description":"`conversion_definitions` on the new profile. Seeded by the database on profile creation, exactly as for a real profile — the demo does not get its own set."}},"required":["touches","outcome_events","conversions","spend_days","definitions"]},"DemoDestination":{"type":"object","properties":{"id":{"type":["string","null"],"example":"uuid","description":"`tracking_export_destinations.id`. Null only when the destination write failed — see `warnings`; the profile and its data are still there."},"mode":{"type":"string","enum":["recording"],"description":"Always `recording`. This is not a destination TYPE — the row is an ordinary Meta CAPI destination with a placeholder pixel id. Recording is a property of the PROFILE: the worker builds the real payload, applies the real privacy and legal gates, writes it to `conversion_exports.payload`, and never calls Meta. The row carries `status: \"skipped\"` and `skip_reason: \"demo_profile_recorded\"`."}},"required":["id","mode"]},"DemoProfileCreated":{"type":"object","properties":{"profile":{"$ref":"#/components/schemas/DemoProfile"},"seeded":{"$ref":"#/components/schemas/DemoSeedCounts"},"destination":{"$ref":"#/components/schemas/DemoDestination"},"created":{"type":"boolean","description":"True only when THIS call created the profile. A second call re-seeds the existing one and answers 200 with `created: false` — the status IS the idempotency signal, and this field repeats it for callers behind a proxy that rewrites statuses."},"warnings":{"type":"array","items":{"type":"string"},"description":"Non-fatal problems during the seed. Empty on a clean run. A seed never half-fails silently: anything that did not land is named here rather than swallowed."}},"required":["profile","seeded","destination","created","warnings"]},"DemoProfileDeleted":{"type":"object","properties":{"deleted":{"type":"boolean"},"profile_id":{"type":["string","null"]},"archived":{"type":"boolean","description":"#1170 — always true alongside `deleted`. The delete is an ARCHIVE + PURGE: the seeded data is removed and the profile row is archived rather than deleted, because `conversion_exports` is audit evidence a database trigger refuses to let any user-facing path erase. The workspace's demo slot is released either way, so you can create a new demo immediately."},"retained_audit_rows":{"type":"integer","description":"How many audit rows stayed attached to the archived profile (conversion exports plus signal-audit entries). Zero for a demo that never ran an export."}},"required":["deleted","profile_id","archived","retained_audit_rows"]},"TrackingSettings":{"type":"object","properties":{"session_timeout_minutes":{"type":"integer","minimum":5,"maximum":120,"example":30,"description":"Minutes of inactivity before the tracker starts a new session."},"session_mode":{"type":"string","enum":["inactivity_only","inactivity_or_source_change"],"description":"`inactivity_only` starts a new session after `session_timeout_minutes` of inactivity and nothing else. `inactivity_or_source_change` ALSO starts one when the visitor arrives from a different marketing source, so a second ad click in the same browsing session is a second visit rather than a continuation of the first."},"enable_geo_enrichment":{"type":"boolean","description":"Resolve country/region/city from the request IP during enrichment."},"enable_user_agent_enrichment":{"type":"boolean","description":"Resolve device/browser/OS from the User-Agent during enrichment."},"enable_channel_classification":{"type":"boolean","description":"Classify each touch into a marketing channel from its UTMs and referrer. Off means every touch stays unclassified."},"export_meta_enabled":{"type":"boolean","description":"READ-ONLY here. Owned by Conversion Sync (the destinations catalogue) — returned so a caller can render current state without a second read, but PATCH does not accept it."},"export_google_enabled":{"type":"boolean","description":"READ-ONLY here, same as `export_meta_enabled`."},"intercept_meta_fbq_enabled":{"type":"boolean","description":"Let the tracker observe the site's own `fbq()` calls, so a form the Meta Pixel already saw is captured even when no Atribu detection layer fired."},"meta_bridge_pageview_enabled":{"type":"boolean","description":"Also mirror Meta Pixel `PageView` events. Off by default: a page view is already captured natively, and mirroring it doubles the volume for no extra signal."},"early_identity_capture_enabled":{"type":"boolean","description":"Per-profile kill switch for pre-submit identity capture (email/phone read at input blur). Default on."}},"required":["session_timeout_minutes","session_mode","enable_geo_enrichment","enable_user_agent_enrichment","enable_channel_classification","export_meta_enabled","export_google_enabled","intercept_meta_fbq_enabled","meta_bridge_pageview_enabled","early_identity_capture_enabled"]},"TrackingSettingsUpdateRequest":{"type":"object","properties":{"session_timeout_minutes":{"type":"integer","minimum":5,"maximum":120},"session_mode":{"type":"string","enum":["inactivity_only","inactivity_or_source_change"]},"enable_geo_enrichment":{"type":"boolean"},"enable_user_agent_enrichment":{"type":"boolean"},"enable_channel_classification":{"type":"boolean"},"intercept_meta_fbq_enabled":{"type":"boolean"},"meta_bridge_pageview_enabled":{"type":"boolean"},"early_identity_capture_enabled":{"type":"boolean"}}},"TrackingSettingsWriteResult":{"allOf":[{"$ref":"#/components/schemas/TrackingSettings"},{"type":"object","properties":{"replay_queued":{"type":"boolean","description":"True when this save enqueued a full-profile attribution replay."},"replay_error":{"type":["string","null"],"description":"Why the replay could not be queued. The settings were still saved — a queue failure is reported, never thrown."}},"required":["replay_queued","replay_error"]}]},"TrackingOrigin":{"type":"object","properties":{"origin":{"type":"string","example":"https://trk.dealer.com"},"host":{"type":"string","example":"trk.dealer.com"}},"required":["origin","host"]},"TrackingEvent":{"type":"object","properties":{"id":{"type":"string"},"event_id":{"type":["string","null"]},"event_name":{"type":"string","example":"lead_submitted"},"event_time":{"type":"string"},"anonymous_id":{"type":["string","null"]},"session_id":{"type":["string","null"]},"customer_profile_id":{"type":["string","null"]},"source_platform":{"type":["string","null"]},"page_url":{"type":["string","null"]},"referrer":{"type":["string","null"]},"utm_source":{"type":["string","null"]},"utm_medium":{"type":["string","null"]},"utm_campaign":{"type":["string","null"]},"utm_content":{"type":["string","null"]},"utm_term":{"type":["string","null"]},"click_ids":{},"value_amount":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"currency":{"type":["string","null"]},"payload":{"description":"The event's own body, verbatim. MAY CONTAIN PII — a `lead_submitted` payload carries the email/phone the tracker captured, which is why this route requires `visitors:read` rather than `analytics:read`."}},"required":["id","event_id","event_name","event_time","anonymous_id","session_id","customer_profile_id","source_platform","page_url","referrer","utm_source","utm_medium","utm_campaign","utm_content","utm_term","value_amount","currency"]},"TrackingEventsResult":{"type":"object","properties":{"events":{"type":"array","items":{"$ref":"#/components/schemas/TrackingEvent"}},"pagination":{"type":"object","properties":{"limit":{"type":"integer"},"next_cursor":{"type":["string","null"]}},"required":["limit","next_cursor"]},"window":{"type":"object","properties":{"days":{"type":"integer","description":"The window ACTUALLY read, after the retention clamp."}},"required":["days"]}},"required":["events","pagination","window"]},"TrackingReplayStep":{"type":"object","properties":{"step_name":{"type":"string"},"status":{"type":"string"},"rows_read":{"type":["number","null"]},"rows_written":{"type":["number","null"]},"error_count":{"type":["number","null"]},"started_at":{"type":["string","null"]},"completed_at":{"type":["string","null"]}},"required":["step_name","status","rows_read","rows_written","error_count","started_at","completed_at"]},"TrackingReplayStatus":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","example":"running"},"started_at":{"type":["string","null"]},"completed_at":{"type":["string","null"]},"rows_read":{"type":["number","null"]},"rows_written":{"type":["number","null"]},"error_count":{"type":["number","null"]},"error_message":{"type":["string","null"]},"updated_at":{"type":["string","null"]},"steps":{"type":"array","items":{"$ref":"#/components/schemas/TrackingReplayStep"}}},"required":["id","status","started_at","completed_at","rows_read","rows_written","error_count","error_message","updated_at","steps"]},"TrackingVerificationAttempt":{"type":"object","properties":{"id":{"type":"string"},"nonce":{"type":"string","description":"Append `?atb_verify=<nonce>` to any tracked URL and load it once."},"status":{"type":"string","enum":["pending","verified","expired"],"description":"`expired` is computed on READ from `expires_at` — no cron flips it, so a pending attempt past its TTL always reads as expired."},"expires_at":{"type":"string"},"verified_at":{"type":["string","null"]},"detected_url":{"type":["string","null"]},"detected_user_agent":{"type":["string","null"]},"created_at":{"type":["string","null"]}},"required":["id","nonce","status","expires_at","verified_at","detected_url","detected_user_agent","created_at"]},"RevokedTrackingKey":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","enum":["revoked"]}},"required":["id","status"]},"TrackingOps":{"type":"object","properties":{"window":{"type":"object","properties":{"days":{"type":"integer"},"from":{"type":"string"},"to":{"type":"string"}},"required":["days","from","to"]},"summary":{"type":"object","properties":{"bad_events":{"type":"integer"},"enrichment_failures":{"type":"integer"},"identity_reassignments":{"type":"integer"},"runs":{"type":"integer"}},"required":["bad_events","enrichment_failures","identity_reassignments","runs"]},"bad_events":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"event_id":{"type":["string","null"]},"event_schema":{"type":["string","null"]},"schema_version":{"type":["number","null"]},"error_stage":{"type":["string","null"]},"error_code":{"type":["string","null"]},"error_message":{"type":["string","null"]},"created_at":{"type":["string","null"]}},"required":["id","event_id","event_schema","schema_version","error_stage","error_code","error_message","created_at"]}},"enrichment_failures":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"stage_name":{"type":["string","null"]},"error_code":{"type":["string","null"]},"error_message":{"type":["string","null"]},"retryable":{"type":["boolean","null"]},"created_at":{"type":["string","null"]}},"required":["id","stage_name","error_code","error_message","retryable","created_at"]}},"identity_history":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"identity_type":{"type":["string","null"]},"identity_value":{"type":["string","null"]},"action":{"type":["string","null"]},"source":{"type":["string","null"]},"previous_canonical_person_id":{"type":["string","null"]},"canonical_person_id":{"type":["string","null"]},"metadata":{},"created_at":{"type":["string","null"]}},"required":["id","identity_type","identity_value","action","source","previous_canonical_person_id","canonical_person_id","created_at"]}},"runs":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"run_key":{"type":["string","null"]},"stage":{"type":["string","null"]},"status":{"type":["string","null"]},"input_lower_bound":{"type":["string","null"]},"input_upper_bound":{"type":["string","null"]},"target_filters":{},"started_at":{"type":["string","null"]},"completed_at":{"type":["string","null"]},"rows_read":{"type":["number","null"]},"rows_written":{"type":["number","null"]},"error_count":{"type":["number","null"]},"error_message":{"type":["string","null"]},"created_at":{"type":["string","null"]}},"required":["id","run_key","stage","status","input_lower_bound","input_upper_bound","started_at","completed_at","rows_read","rows_written","error_count","error_message","created_at"]}},"run_steps":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"manifest_id":{"type":["string","null"]},"step_name":{"type":["string","null"]},"status":{"type":["string","null"]},"started_at":{"type":["string","null"]},"completed_at":{"type":["string","null"]},"rows_read":{"type":["number","null"]},"rows_written":{"type":["number","null"]},"error_count":{"type":["number","null"]},"details":{},"created_at":{"type":["string","null"]}},"required":["id","manifest_id","step_name","status","started_at","completed_at","rows_read","rows_written","error_count","created_at"]}}},"required":["window","summary","bad_events","enrichment_failures","identity_history","runs","run_steps"]},"TrackingQuality":{"type":"object","properties":{"window":{"type":"object","properties":{"days":{"type":"integer"},"from":{"type":"string"},"to":{"type":"string"}},"required":["days","from","to"]},"summary":{"$ref":"#/components/schemas/TrackingQualitySummary"},"business_conversions":{"type":["number","null"],"description":"The profile's conversions over the same window. NULL (never 0) when the count could not be read — a failed read rendered as zero is a wrong number read as fact."},"breakdown":{"type":"array","items":{"type":"object","properties":{"event_name":{"type":"string"},"count":{"type":"number"}},"required":["event_name","count"]}},"diagnostics":{"type":"object","properties":{"meta_bridge":{"type":"object","properties":{"events":{"type":"number"},"missing_browser_ids":{"type":"integer","description":"Bridge events carrying neither `fbp` nor `fbc` — unmatchable at Meta."}},"required":["events","missing_browser_ids"]},"meta_exports":{"type":"object","properties":{"sent":{"type":"number","description":"A count OVER THE SAMPLE, not a total. Read it with `sampled_conversions` and `complete`."},"fallback_event_id_count":{"type":"number","description":"Exports whose `external_event_id` fell back to the raw outcome-event id instead of the booking cluster's canonical id — those cannot dedup against the Pixel at Meta."},"sampled_conversions":{"type":"number"},"window_conversions":{"type":["number","null"],"description":"The window's true conversion total, or null when it could not be read."},"complete":{"type":"boolean","description":"True when the sample covered the whole window, so `sent` IS the total. False means `sent` is 'N of the most recent `sampled_conversions`'."}},"required":["sent","fallback_event_id_count","sampled_conversions","window_conversions","complete"]},"warehouse":{"type":"object","properties":{"bad_events":{"type":"number"},"enrichment_failures":{"type":"number"},"identity_reassignments":{"type":"number"}},"required":["bad_events","enrichment_failures","identity_reassignments"]}},"required":["meta_bridge","meta_exports","warehouse"]}},"required":["window","summary","business_conversions","breakdown","diagnostics"]},"TrackingQualitySummary":{"type":"object","properties":{"window_start":{"type":"string"},"window_end":{"type":"string"},"total_events":{"type":"number"},"page_view_events":{"type":"number"},"conversion_events":{"type":"number","description":"The TRACKER's conversion events. Not the profile's conversions — that is `business_conversions`, and the two are routinely orders of magnitude apart because most conversions arrive from a CRM or a payment provider, never through a browser."},"identify_events":{"type":"number"},"identified_events":{"type":"number"},"unique_anonymous_ids":{"type":"integer"},"events_with_click_ids":{"type":"integer"},"conversion_events_with_click_ids":{"type":"integer"},"identify_rate":{"type":"number"},"click_id_rate":{"type":"number"},"conversion_click_id_rate":{"type":"number"}},"required":["window_start","window_end","total_events","page_view_events","conversion_events","identify_events","identified_events","unique_anonymous_ids","events_with_click_ids","conversion_events_with_click_ids","identify_rate","click_id_rate","conversion_click_id_rate"]},"TrackingDomain":{"type":"object","properties":{"id":{"type":"string"},"domain":{"type":"string","example":"trk.dealer.com"},"status":{"type":"string","enum":["pending","verified","failed"]},"expected_cname":{"type":"string","example":"collect.atribu.app"},"setup_method":{"type":"string","enum":["manual","domain_connect"]},"verified_at":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","domain","status","expected_cname","setup_method","verified_at","created_at"]},"TrackingDomainList":{"type":"object","properties":{"domains":{"type":"array","items":{"$ref":"#/components/schemas/TrackingDomain"}},"cname_target":{"type":["string","null"],"description":"The CNAME every domain on this profile must point at. Null when this deployment has no collect host configured — creating a domain then answers 503."}},"required":["domains","cname_target"]},"CreateTrackingDomainRequest":{"type":"object","properties":{"domain":{"type":"string","minLength":4,"maxLength":253,"example":"trk.dealer.com","description":"The hostname to serve tracking from. A scheme and path are stripped, so `https://trk.dealer.com/` is accepted and stored as `trk.dealer.com`."}},"required":["domain"]},"CreateTrackingDomainResult":{"type":"object","properties":{"domain":{"$ref":"#/components/schemas/TrackingDomain"},"instructions":{"type":"object","properties":{"step1":{"type":"string"},"step2":{"type":"string"},"step3":{"type":"string"}},"required":["step1","step2","step3"]}},"required":["domain","instructions"]},"VerifyTrackingDomainResult":{"type":"object","properties":{"verified":{"type":"boolean"},"domain":{"type":"string"},"status":{"type":"string","enum":["verified","failed"]},"message":{"type":"string"}},"required":["verified","domain","status","message"]},"DeletedTrackingDomain":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean","enum":[true]}},"required":["id","deleted"]},"ExportReviewItem":{"type":"object","properties":{"id":{"type":"string"},"destination":{"type":"string","example":"meta_capi"},"status":{"type":"string","example":"sent"},"attempt_count":{"type":"integer"},"created_at":{"type":"string"},"updated_at":{"type":"string"},"external_event_id":{"type":["string","null"],"description":"The id Meta/Google dedups on. Null means nothing was ever sent under an id — see the `missing_external_event_id` warning."},"last_error":{"type":["string","null"]},"outcome_event":{"type":["object","null"],"properties":{"id":{"type":"string"},"event_type":{"type":"string"},"event_time":{"type":"string"},"value_amount":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"currency":{"type":["string","null"]}},"required":["id","event_type","event_time","value_amount","currency"],"description":"The conversion this delivery carries. Null when the export outlived the outcome event it pointed at."}},"required":["id","destination","status","attempt_count","created_at","updated_at","external_event_id","last_error","outcome_event"]},"ExportReview":{"type":"object","properties":{"rows":{"type":"array","items":{"$ref":"#/components/schemas/ExportReviewItem"}},"summary":{"type":"object","properties":{"total":{"type":"integer"},"by_status":{"type":"object","additionalProperties":{"type":"number"}},"by_destination":{"type":"object","additionalProperties":{"type":"number"}},"failed":{"type":"integer"},"pending":{"type":"integer"}},"required":["total","by_status","by_destination","failed","pending"]}},"required":["rows","summary"]},"ExportReviewWarnings":{"type":"object","properties":{"warnings":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","enum":["stale_pending","high_failure_rate","missing_external_event_id","high_retry_volume"]},"severity":{"type":"string","enum":["warning","critical"]},"message":{"type":"string"},"count":{"type":"integer"}},"required":["code","severity","message","count"]}}},"required":["warnings"]},"ExportRetryRequest":{"type":"object","properties":{"destination":{"type":"string","enum":["meta_capi","google_ads"],"description":"Narrow the retry to one destination. Omit to retry both."},"max_rows":{"type":"integer","minimum":1,"maximum":200}}},"ExportRetryResult":{"type":"object","properties":{"requested":{"type":"integer"},"updated":{"type":"integer"},"dispatch":{"type":"object","properties":{"triggered":{"type":"boolean"},"jobs_enqueued":{"type":"integer"}},"required":["triggered","jobs_enqueued"]}},"required":["requested","updated","dispatch"]},"OrganicPost":{"type":"object","properties":{"post_id":{"type":"string","description":"The platform's own post id — Instagram `ig_media_id` or Facebook `fb_post_id`. This is the value the two detail routes take as `{id}`."},"platform":{"type":"string","enum":["instagram","facebook"]},"media_type":{"type":["string","null"],"description":"IMAGE | VIDEO | CAROUSEL_ALBUM. Facebook attachment types are normalised into the same taxonomy."},"media_product_type":{"type":["string","null"],"description":"FEED | REELS | STORY. Always FEED for Facebook — /posts has no Reels analogue."},"caption_excerpt":{"type":["string","null"],"description":"First 240 characters of the caption (Instagram) or message (Facebook)."},"permalink":{"type":["string","null"]},"thumbnail_url":{"type":["string","null"]},"posted_at":{"type":["string","null"]},"page_name":{"type":["string","null"],"description":"The MERCHANT's own account — Instagram username or Facebook page name."},"page_username":{"type":["string","null"]},"like_count":{"type":"number"},"comments_count":{"type":"number"},"shares":{"type":"number"},"saved":{"type":"number"},"views":{"type":"number"},"reach":{"type":"number"},"total_interactions":{"type":"number"},"touches_count":{"type":"number","description":"Organic touches recorded against this post inside the window."},"attributed_conversions":{"type":"number"},"attributed_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Cash credit only. Pipeline value is never folded in."},"ai_score":{"type":["number","null"],"description":"Null until the AI content scorer has run against this post."},"fatigue_score":{"type":["number","null"]},"theme_cluster_id":{"type":["string","null"]},"visible_on_bio":{"type":"boolean","description":"Instagram only; always false for Facebook."}},"required":["post_id","platform","media_type","media_product_type","caption_excerpt","permalink","thumbnail_url","posted_at","page_name","page_username","like_count","comments_count","shares","saved","views","reach","total_interactions","touches_count","attributed_conversions","attributed_revenue","ai_score","fatigue_score","theme_cluster_id","visible_on_bio"]},"OrganicAttributedCustomer":{"type":"object","properties":{"customer_profile_id":{"type":"string"},"name":{"type":["string","null"]},"email":{"type":["string","null"]},"phone":{"type":["string","null"]},"credit_value_reporting":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"This post's or story's credited share of the customer's cash conversions, in the profile's reporting currency."},"conv_count":{"type":"number"},"last_conversion_time":{"type":["string","null"]},"touch_subtype":{"type":["string","null"],"description":"How the customer touched the content — e.g. `story_reply`, `dm`."},"first_touch_time":{"type":["string","null"],"description":"Post detail only; the story read does not compute it."}},"required":["customer_profile_id","name","email","phone","credit_value_reporting","conv_count","last_conversion_time","touch_subtype"]},"OrganicPostAttribution":{"type":"object","properties":{"kpis":{"type":"object","properties":{"attributed_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"conv_count":{"type":"number"},"touches_count":{"type":"number"},"avg_credit_weight":{"type":"number"},"attribution_model":{"type":"string"}},"required":["attributed_revenue","conv_count","touches_count","avg_credit_weight","attribution_model"]},"customers":{"type":["array","null"],"items":{"$ref":"#/components/schemas/OrganicAttributedCustomer"},"description":"NULL — not `[]` — unless the key also holds `customers:read`. An empty array would be indistinguishable from 'nobody', which is a fact you would act on; null says 'not returned to this key'."},"model_breakdown":{"type":"array","items":{"type":"object","properties":{"attribution_model":{"type":"string"},"credit_value_reporting":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"conv_count":{"type":"number"}},"required":["attribution_model","credit_value_reporting","conv_count"]},"description":"The same post's credit under EVERY model, not only the requested one — the read computes it in the same pass."},"daily_trend":{"type":"array","items":{"type":"object","properties":{"day":{"type":"string"},"credited_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["day","credited_revenue"]},"description":"Credited cash by conversion day, last 30 days of the window."}},"required":["kpis","customers","model_breakdown","daily_trend"]},"OrganicPostDetail":{"type":"object","properties":{"post_id":{"type":"string"},"platform":{"type":"string","enum":["instagram","facebook"]},"attribution":{"allOf":[{"$ref":"#/components/schemas/OrganicPostAttribution"},{"type":["object","null"],"description":"NULL for a Facebook post, and that is a coverage statement rather than an empty result: per-post attribution detail resolves touches by `ig_media_id`, so it exists for Instagram only today. The Facebook numbers are on the post's row in `GET /api/v1/organic/posts`."}]}},"required":["post_id","platform","attribution"]},"OrganicPostAnalysis":{"type":"object","properties":{"performance_score":{"type":["number","null"],"description":"0-10. The scorer's headline read on the creative."},"fatigue_score":{"type":["number","null"],"description":"0-1. Higher means the creative repeats what this account already posted."},"hook_effectiveness":{"type":["string","null"]},"cta_clarity":{"type":["string","null"]},"emotional_appeal":{"type":["string","null"]},"audience_fit":{"type":["string","null"]},"strengths":{"type":"array","items":{"type":"string"}},"weaknesses":{"type":"array","items":{"type":"string"}},"suggestions":{"type":"array","items":{"type":"string"}},"theme_cluster_id":{"type":["string","null"],"description":"Groups posts the scorer judged to be the same creative theme."},"model_id":{"type":"string","description":"Which analyzer produced this row. A newer model supersedes an older one."},"analyzed_at":{"type":"string"}},"required":["performance_score","fatigue_score","hook_effectiveness","cta_clarity","emotional_appeal","audience_fit","strengths","weaknesses","suggestions","theme_cluster_id","model_id","analyzed_at"]},"OrganicPostTwinAd":{"type":"object","properties":{"ad_external_id":{"type":"string","description":"The PLATFORM's ad id — the same identifier `GET /api/v1/ads/{id}/creative-analysis` and `GET /api/v1/top-performers` use, so a twin can be followed straight to the ad."},"ad_name":{"type":["string","null"]},"campaign_external_id":{"type":["string","null"]},"campaign_name":{"type":["string","null"]},"ad_thumbnail_url":{"type":["string","null"]},"similarity":{"type":"number","description":"0-1 cosine similarity between this post's caption embedding and the ad's creative-copy embedding. It RANKS candidates; it does not predict performance — two ads at 0.94 can perform nothing alike."},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"LIFETIME spend on the ad, in the profile's reporting currency."},"attributed_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"LIFETIME cash revenue credited to the ad. Pipeline value is never folded in."}},"required":["ad_external_id","ad_name","campaign_external_id","campaign_name","ad_thumbnail_url","similarity","spend","attributed_revenue"]},"OrganicPostIntelligence":{"type":"object","properties":{"post_id":{"type":"string"},"platform":{"type":"string","enum":["instagram","facebook"]},"analysis":{"allOf":[{"$ref":"#/components/schemas/OrganicPostAnalysis"},{"type":["object","null"],"description":"NULL when the analysis pass has never covered this post. That is COVERAGE, not a verdict: the pass is a metered background job and this route never runs it."}]},"twin_ads":{"type":"array","items":{"$ref":"#/components/schemas/OrganicPostTwinAd"},"description":"`[]` — not null — when nothing matched: the profile has no paid creative near this caption, or has no creative embeddings yet."}},"required":["post_id","platform","analysis","twin_ads"]},"OrganicStoryDetail":{"type":"object","properties":{"story":{"$ref":"#/components/schemas/OrganicStory"},"kpis":{"type":"object","properties":{"attributed_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"conv_count":{"type":"number"},"replies_count":{"type":"number"},"touches_count":{"type":"number"},"attribution_model":{"type":"string"}},"required":["attributed_revenue","conv_count","replies_count","touches_count","attribution_model"]},"customers":{"type":["array","null"],"items":{"$ref":"#/components/schemas/OrganicAttributedCustomer"},"description":"NULL — not `[]` — unless the key also holds `customers:read`. An empty array would be indistinguishable from 'nobody', which is a fact you would act on; null says 'not returned to this key'."},"replies":{"type":["array","null"],"items":{"type":"object","properties":{"customer_profile_id":{"type":["string","null"]},"customer_name":{"type":["string","null"]},"touch_time":{"type":"string"},"text_excerpt":{"type":["string","null"]}},"required":["customer_profile_id","customer_name","touch_time","text_excerpt"]},"description":"Up to 50 story replies, newest first. The sender AND the text they sent are identity: NULL — not `[]` — unless the key also holds `customers:read`. An empty array would be indistinguishable from 'nobody', which is a fact you would act on; null says 'not returned to this key'."}},"required":["story","kpis","customers","replies"]},"OrganicStory":{"type":"object","properties":{"ig_media_id":{"type":"string"},"instagram_account_id":{"type":["string","null"]},"impressions":{"type":["number","null"]},"reach":{"type":["number","null"]},"replies":{"type":["number","null"],"description":"Replies Instagram counted. `kpis.replies_count` is how many Atribu matched to a touch."},"exits":{"type":["number","null"]},"taps_forward":{"type":["number","null"]},"taps_back":{"type":["number","null"]},"navigation":{"type":["number","null"]},"posted_at":{"type":["string","null"]}},"required":["ig_media_id","instagram_account_id","impressions","reach","replies","exits","taps_forward","taps_back","navigation","posted_at"]},"date_from":{"type":"string","example":"2026-03-01","description":"Start date (YYYY-MM-DD, inclusive)"},"date_to":{"type":"string","example":"2026-03-25","description":"End date (YYYY-MM-DD, inclusive). Max range: 366 days."},"limit":{"type":"string","example":"10","description":"Max results to return (default 10, max 100)"},"model":{"type":"string","example":"last_touch","description":"Attribution model. One of: last_touch, first_touch, linear, time_decay, last_non_direct"},"AdCreativeAnalysisResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AdCreativeAnalysis"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"AdCreativeAnalysis":{"type":"object","properties":{"ad_external_id":{"type":"string","example":"120210000000000001"},"ad_name":{"type":["string","null"]},"analysis_version":{"type":"number","description":"The analysis schema this row was written under. The route serves the current version only, so this is informational, not a selector."},"method":{"type":"string","description":"`llm_vision_v2` (video analysed) | `llm_thumbnail_v2` (still image only) | `llm_copy_only_v2` (no visual). The pass degrades down this ladder rather than failing when a video source cannot be resolved.","example":"llm_vision_v2"},"model_id":{"type":["string","null"],"example":"gemini-2.5-flash"},"confidence":{"type":["string","null"],"description":"low | medium | high."},"video_source_resolved":{"type":"boolean"},"analyzed_at":{"type":["string","null"]},"creative":{"$ref":"#/components/schemas/AdCreativeFingerprint"},"video":{"$ref":"#/components/schemas/AdCreativeVideoAnalysis"}},"required":["ad_external_id","ad_name","analysis_version","method","model_id","confidence","video_source_resolved","analyzed_at","creative","video"]},"AdCreativeFingerprint":{"type":"object","properties":{"primary_angle":{"type":["string","null"],"description":"One line naming the specific promise the creative makes, in its own language."},"offers":{"type":"array","items":{"type":"string"}},"promised_outcomes":{"type":"array","items":{"type":"string"}},"pain_points_addressed":{"type":"array","items":{"type":"string"}},"audience_targeted":{"type":"array","items":{"type":"string"}},"cta_type":{"type":["string","null"]},"hook_type":{"type":["string","null"]},"claim_style":{"type":["string","null"]},"camera_shot":{"type":["string","null"]},"creative_format":{"type":["string","null"]},"language_detected":{"type":["string","null"],"example":"es"},"text_density":{"type":["string","null"],"description":"none | light | medium | heavy."},"audience_warmth":{"type":["string","null"],"description":"prospecting | retargeting | unknown — the cohort dimension this feeds."},"audience_warmth_confidence":{"type":["string","null"],"description":"low | medium | high."}},"required":["primary_angle","offers","promised_outcomes","pain_points_addressed","audience_targeted","cta_type","hook_type","claim_style","camera_shot","creative_format","language_detected","text_density","audience_warmth","audience_warmth_confidence"]},"AdCreativeVideoAnalysis":{"type":"object","properties":{"transcript":{"type":["string","null"]},"transcript_word_count":{"type":["number","null"]},"visual_summary":{"type":["string","null"]},"opening_frame":{"type":["string","null"]},"emotional_arc":{"type":["string","null"]},"cta_placement_seconds":{"type":["number","null"]},"n_scenes":{"type":["number","null"]},"cuts_per_minute":{"type":["number","null"]},"hook_duration_s":{"type":["number","null"]},"time_to_first_cta_s":{"type":["number","null"]},"onscreen_text_coverage_pct":{"type":["number","null"]},"has_product_demo":{"type":["boolean","null"]},"has_testimonial":{"type":["boolean","null"]},"has_pattern_interrupt":{"type":["boolean","null"]},"dominant_scene_role":{"type":["string","null"],"description":"The beat role the most screen time went to."},"narrative_arc_pattern":{"type":["string","null"],"description":"The beat-role sequence, collapsed.","example":"hook>problem>solution>cta"},"scenes":{"type":"array","items":{"$ref":"#/components/schemas/CreativeSceneBeat"}}},"required":["transcript","transcript_word_count","visual_summary","opening_frame","emotional_arc","cta_placement_seconds","n_scenes","cuts_per_minute","hook_duration_s","time_to_first_cta_s","onscreen_text_coverage_pct","has_product_demo","has_testimonial","has_pattern_interrupt","dominant_scene_role","narrative_arc_pattern","scenes"],"description":"The visual/temporal half. NULL-heavy by design: populated only when `method` is `llm_vision_v2`. On a thumbnail-only or copy-only pass every time-based field is null and `scenes` is empty — a null here means 'not observed', never 'the ad does not do this'."},"CreativeSceneBeat":{"type":"object","properties":{"start_s":{"type":["number","null"],"example":0},"end_s":{"type":["number","null"],"example":2.4},"role":{"type":["string","null"],"description":"hook | problem | solution | product_demo | social_proof | offer | cta | b_roll | other. A hint, not an enum — the extraction schema accepts free text so a single off-vocabulary beat cannot fail the whole analysis.","example":"hook"},"on_screen_text":{"type":["string","null"],"description":"Verbatim on-screen text or captions visible during this beat."},"spoken_excerpt":{"type":["string","null"],"description":"Verbatim excerpt of what is said during this beat."},"visual_description":{"type":["string","null"]},"camera_shot":{"type":["string","null"],"description":"handheld | static | split_screen | talking_head | b_roll | screen_recording | other. A hint, not an enum."},"what_changed":{"type":["string","null"],"description":"The specific change from the previous beat."}},"required":["start_s","end_s","role","on_screen_text","spoken_excerpt","visual_description","camera_shot","what_changed"]},"AdCreativeAnalysisJobResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AdCreativeAnalysisJob"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"AdCreativeAnalysisJob":{"type":"object","properties":{"id":{"type":"string","description":"The job id. Stable for the life of the 24h idempotency window: a second POST for the same ad and the same creative returns THIS id rather than minting a new one.","example":"8f2b1c44-0a3d-4d1e-9c77-2f5b6a0c1d33"},"ad_external_id":{"type":"string","example":"120210000000000001"},"status":{"type":"string","enum":["queued","running","succeeded","failed","blocked"],"description":"The returned job is always live — `queued`, `running` or `succeeded`. A terminal failure is never handed back as the current job (see `previous_attempt`).","example":"queued"},"reused":{"type":"boolean","description":"True when an existing job came back instead of a new one — the same fact the 200-vs-202 status carries, repeated here so it survives in a logged body."},"requested_at":{"type":["string","null"],"description":"When the returned job was FIRST requested, which on a reuse is earlier than this call."},"analysis_version":{"type":"number","description":"The analysis schema the job will write under."},"input_hash":{"type":"string","description":"The creative's source signature at request time — the idempotency key. Edit the ad's name, headline, body, video or thumbnail and this changes, which is what makes the next POST a new job rather than a reuse."},"previous_attempt":{"type":["object","null"],"properties":{"status":{"type":"string","enum":["failed","blocked"]},"error":{"type":["string","null"]}},"required":["status","error"],"description":"The most recent attempt at this same creative that ended terminally, if there was one inside the window. `failed` means the run was attempted and did not produce an analysis; `blocked` means it was never attempted — either the background analysis pass is switched off for this deployment, or the workspace's daily creative-analysis allowance was still spent after the job had already waited a full UTC day — and cost nothing against your daily budget. A failed or blocked job is deliberately NOT reused, so this is how you see it."},"budget":{"type":"object","properties":{"profile_used":{"type":"number"},"profile_cap":{"type":"number"},"api_key_used":{"type":"number"},"api_key_cap":{"type":"number"}},"required":["profile_used","profile_cap","api_key_used","api_key_cap"],"description":"Where this request left the two daily budgets, both per UTC day. `api_key_*` is 0/0 for a session credential, which has no key to charge. A reuse consumes nothing and reports zeros."}},"required":["id","ad_external_id","status","reused","requested_at","analysis_version","input_hash","previous_attempt","budget"]},"AdsLevelCounts":{"type":"object","properties":{"campaign":{"type":"number","example":12},"ad_set":{"type":"number","example":48},"ad":{"type":"number","example":311}},"required":["campaign","ad_set","ad"]},"AdsUnattributed":{"type":"object","properties":{"outcome_count":{"type":"number","example":14},"outcome_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":4820.5,"description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"outcome_count_first_payment":{"type":"integer","example":9},"outcome_value_first_payment":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":2110,"description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["outcome_count","outcome_value","outcome_count_first_payment","outcome_value_first_payment"]},"AdTrendPoint":{"type":"object","properties":{"platform_id":{"type":"string","example":"120210000000000042"},"date":{"type":"string","example":"2026-08-14"},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":41.2,"description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"clicks":{"type":"number","example":88}},"required":["platform_id","date","spend","clicks"]},"AdDetail":{"type":"object","properties":{"entity_id":{"type":"string","description":"The internal `ad_entity_dim` uuid. Never a public identifier — use `platform_id`."},"platform_id":{"type":"string","example":"120210000000000042"},"entity_name":{"type":"string"},"entity_level":{"type":"string","enum":["campaign","ad_set","ad"]},"parent_name":{"type":["string","null"]},"campaign_external_id":{"type":["string","null"]},"ad_set_external_id":{"type":["string","null"]},"status":{"type":["string","null"],"example":"ACTIVE"},"objective":{"type":["string","null"],"example":"OUTCOME_SALES"},"daily_budget":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"impressions":{"type":"number"},"reach":{"type":"number"},"clicks":{"type":"number"},"ctr":{"type":"number"},"avg_cpm":{"type":"number"},"avg_cpc":{"type":"number"},"frequency":{"type":"number","description":"impressions / reach, 0 when the entity had no impressions in the window."},"outcome_count":{"type":"number"},"outcome_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"roas":{"type":"number"},"cac":{"type":"number"},"outcome_count_first_payment":{"type":"integer"},"outcome_value_first_payment":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"roas_first_payment":{"type":"number"},"cac_first_payment":{"type":"number"},"creative_thumbnail_url":{"type":["string","null"]},"creative_body":{"type":["string","null"]},"creative_title":{"type":["string","null"]},"video_id":{"type":["string","null"]},"video_plays":{"type":"number"},"video_p25":{"type":"number"},"video_p50":{"type":"number"},"video_p75":{"type":"number"},"video_p100":{"type":"number"},"video_avg_time_watched":{"type":"number"},"video_thruplays":{"type":"number"},"hook_rate":{"type":"number"},"retention_rate":{"type":"number"},"completion_rate":{"type":"number"},"thumb_stop_ratio":{"type":"number"},"video_play_rate":{"type":"number"},"messaging_total_connections":{"type":"number"},"messaging_conversations_started_7d":{"type":"number"},"messaging_conversations_replied_7d":{"type":"number"},"messaging_first_reply":{"type":"number"},"messaging_user_depth_2":{"type":"number"},"messaging_user_depth_3":{"type":"number"},"messaging_user_depth_5":{"type":"number"},"messaging_block":{"type":"number"},"cost_per_conversation_started":{"type":"number"},"first_reply_rate":{"type":"number"},"depth_3_rate":{"type":"number"}},"required":["entity_id","platform_id","entity_name","entity_level","parent_name","campaign_external_id","ad_set_external_id","status","objective","daily_budget","spend","impressions","reach","clicks","ctr","avg_cpm","avg_cpc","frequency","outcome_count","outcome_value","roas","cac","outcome_count_first_payment","outcome_value_first_payment","roas_first_payment","cac_first_payment","creative_thumbnail_url","creative_body","creative_title","video_id","video_plays","video_p25","video_p50","video_p75","video_p100","video_avg_time_watched","video_thruplays","hook_rate","retention_rate","completion_rate","thumb_stop_ratio","video_play_rate","messaging_total_connections","messaging_conversations_started_7d","messaging_conversations_replied_7d","messaging_first_reply","messaging_user_depth_2","messaging_user_depth_3","messaging_user_depth_5","messaging_block","cost_per_conversation_started","first_reply_rate","depth_3_rate"],"description":"A strict superset of the row `GET /api/v1/campaigns?level=` returns, with the fields the list shape drops: the first-payment metric pair, the creative copy, the video funnel and the messaging funnel.\n\nTHE THREE OUTCOME FAMILIES ARE DISTINCT AND DO NOT ADD UP. `outcome_*` is every attributed conversion of any revenue type; `*_first_payment` is the first-payment-only SUBSET of it; `messaging_*` counts conversations, not cash. Adding two of them together produces a number that describes nothing."},"AdPlacement":{"type":"object","properties":{"publisher_platform":{"type":"string","example":"instagram"},"platform_position":{"type":"string","example":"instagram_reels"},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"In the AD ACCOUNT's billing currency."},"spend_reporting":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"The same money in the profile's reporting currency, or null when no FX rate applied."},"impressions":{"type":"number"},"clicks":{"type":"number"},"reach":{"type":"number"},"ctr":{"type":"number","description":"Percent, 0–100 — clicks / impressions × 100."},"cost_per_click":{"type":"number","description":"Prefers `spend_reporting`, falling back to `spend`. The two are never mixed inside one figure — that would give a currency-crossing profile a CPC correct in neither."}},"required":["publisher_platform","platform_position","spend","spend_reporting","impressions","clicks","reach","ctr","cost_per_click"]},"AdsSignalsSummary":{"type":"object","properties":{"active_rule_count":{"type":"number","example":4},"stage_rule_count":{"type":"number","description":"The subset of active rules fired by a CRM pipeline-stage transition.","example":2},"meta_destination_count":{"type":"number","example":1},"google_destination_count":{"type":"number","example":0}},"required":["active_rule_count","stage_rule_count","meta_destination_count","google_destination_count"]},"UtmHealthRefreshRequest":{"type":"object","properties":{"force":{"type":"boolean","description":"Scan even when the cached summary is inside the 10-minute idempotency window."}}},"PendingConnection":{"type":"object","properties":{"provider":{"type":"string","enum":["meta_ads","google_ads","google_search_console","gohighlevel"],"description":"The `connection_provider` value — the same string `GET /api/v1/connections` returns as `channel` and the same one stored on the connection. Only these four have a consent-then-pick flow; Google Calendar finalizes inside its own callback.","example":"meta_ads"},"expires_at":{"type":"string","description":"When the parked provider token dies (60 minutes from the consent). After this the consent is gone and the connect flow must be started again.","example":"2026-08-25T19:00:00.000Z"},"candidate_count":{"type":"number","description":"How many candidates the OAuth callback counted when it parked the token. `candidates` below is what the provider reports NOW; the two may legitimately differ if an account was added or removed in between, and the list is what you should render.","example":3},"warning":{"type":["string","null"],"description":"An operator-facing note when the list is degraded — today only GoHighLevel produces one (a location-scoped token that cannot enumerate sub-accounts, or a missing `oauth.readonly` scope, so names may appear as ids). Not an error: the candidates returned alongside it are real and finalizable."},"candidates":{"type":"array","items":{"$ref":"#/components/schemas/PendingConnectionCandidate"}}},"required":["provider","expires_at","candidate_count","warning","candidates"]},"PendingConnectionCandidate":{"type":"object","properties":{"id":{"type":"string","description":"Pass this back verbatim as `candidate_id`. It is the provider's own identifier and becomes the connection's `external_account_id` — an `act_…` ad account (Meta), a bare customer id (Google Ads), a `sc-domain:` or URL property (Search Console), a location id (GoHighLevel).","example":"act_1234567890"},"name":{"type":"string","description":"Display label. Falls back to `id` when the provider returns no name.","example":"Dealer Ads — Santiago"},"currency":{"type":["string","null"],"description":"meta_ads / google_ads. ISO-4217.","example":"CLP"},"account_status":{"type":["number","null"],"description":"meta_ads only. Meta's numeric ad-account status (1 = active)."},"time_zone":{"type":["string","null"],"description":"meta_ads / google_ads. IANA zone."},"manager":{"type":["boolean","null"],"description":"google_ads only. True for a manager (MCC) account. Managers are not offered as candidates — their child accounts are listed instead — so this is informational."},"login_customer_id":{"type":["string","null"],"description":"google_ads only. The MCC this account is reached through, when it is."},"permission_level":{"type":["string","null"],"description":"google_search_console only. `siteOwner` | `siteFullUser` | …"},"company_id":{"type":["string","null"],"description":"gohighlevel only. The agency this sub-account belongs to."}},"required":["id","name","currency","account_status","time_zone","manager","login_customer_id","permission_level","company_id"],"description":"One account/property/location the dealer can pick. One flat shape across all four providers: `id` and `name` are always present, the rest are null for providers that do not have them."},"FinalizePendingConnectionResult":{"type":"object","properties":{"provider":{"type":"string","enum":["meta_ads","google_ads","google_search_console","gohighlevel"],"description":"The `connection_provider` value — the same string `GET /api/v1/connections` returns as `channel` and the same one stored on the connection. Only these four have a consent-then-pick flow; Google Calendar finalizes inside its own callback.","example":"meta_ads"},"already_finalized":{"type":"boolean","description":"True when this call changed nothing because the same `candidate_id` had already been finalized. Retries are safe and answer 200 either way."},"connection":{"$ref":"#/components/schemas/FinalizedConnection"}},"required":["provider","already_finalized","connection"]},"FinalizedConnection":{"type":"object","properties":{"id":{"type":"string","description":"The connection's Atribu id (uuid)."},"external_account_id":{"type":"string","description":"The candidate id that was connected.","example":"act_1234567890"},"external_account_name":{"type":["string","null"]},"status":{"type":"string","example":"connected"},"currency":{"type":["string","null"],"description":"Meta only, and only when the ad account reports one."},"created_at":{"type":"string"}},"required":["id","external_account_id","external_account_name","status","currency","created_at"]},"Connection":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"channel":{"type":"string","enum":["whatsapp","instagram","email","google_calendar","google_calendar_read","microsoft_calendar_read","meta_ads","google_ads","google_search_console","gohighlevel","shopify","stripe","mercadopago","notion","manychat","fintoc"]},"status":{"type":"string","description":"Connection status: 'connected'|'syncing'|'error'|'disconnected' for every channel. Email connections additionally re-derive 'connected'/'syncing' into 'degraded' when the provider watch/subscription has expired AND no message has been processed in the last 24h, and Stripe connections do the same when disputes are unreadable on this account (missing Connect application permission) while payments and refunds keep syncing normally — see `status_reason` for either case."},"status_reason":{"type":["string","null"],"description":"Human-readable reason for a non-nominal `status`. Populated for email connections when the provider watch/subscription has expired and no message has been processed recently (`degraded`), and for Stripe connections when disputes are unreadable on this account for lack of a Connect application permission (`degraded`; payments and refunds are unaffected). `null` when `status` needs no explanation."},"display_name":{"type":["string","null"],"description":"Human-readable connection label (WA: phone number; IG: @username; email: mailbox address; calendar: Google account email; everything else: the ad account / store / property / location name the connect recorded)."},"external_id":{"type":["string","null"],"description":"WA phone_number_id, IG ig_business_account_id, email mailbox address, Google account email, or — for every non-messaging kind — the provider's own account id, which for the picker providers is the value passed to `POST /api/v1/connections/pending/{provider}/finalize` as `candidate_id`."},"provider_subtype":{"type":["string","null"],"description":"Instagram: 'fb_login'|'ig_login'. Email: 'gmail'|'outlook'. Null otherwise."},"authorized_at":{"type":["string","null"],"description":"When your app was authorized for THIS connection. Always null for every non-messaging kind (those are not delegated per connection) and for direct admin keys, which hold no authorization row."},"created_at":{"type":"string","description":"When the connection was created — i.e. connected."},"kind":{"type":"string","enum":["messaging","ad_platform","crm","commerce","payments","integration"],"description":"Which class this connection belongs to.\n\n`messaging` — WhatsApp / Instagram / email / Google & Microsoft Calendar, delegated to your app one connection at a time, so an OAuth-app key sees only the ones it was authorized for. Every other kind is listed for the key's profile and carries no `authorized_at`.\n\n`ad_platform` — Meta Ads, Google Ads, Search Console. `crm` — GoHighLevel. `commerce` — Shopify. `payments` — Stripe, MercadoPago. `integration` — Notion, ManyChat.\n\n**Changed 2026-08-26:** `gohighlevel` reports `crm`, not `ad_platform`. The four non-messaging kinds beyond `ad_platform` are new in the same release, which is when this list stopped being 'messaging or not' — see the API changelog."},"currency":{"type":["string","null"],"description":"The connection's reporting currency (ISO-4217), when the provider reports one — Meta and Google Ads do. Null for every messaging channel, and for any provider whose connect does not record one."},"sync":{"$ref":"#/components/schemas/ConnectionSync"}},"required":["id","channel","status","status_reason","display_name","external_id","provider_subtype","authorized_at","created_at","kind","currency","sync"]},"ConnectionSync":{"type":"object","properties":{"last_synced_at":{"type":["string","null"],"description":"When a sync for this connection last COMPLETED, ISO-8601. `null` means it has never completed one — normal for a channel that is push-driven rather than polled (email, calendar)."},"last_error":{"type":["string","null"],"description":"The connector's own failure text from the last run, and the only explanation a `status: \"error\"` connection carries. Distinct from `status_reason`, which is the derived, email-only sentence about a stale provider watch."},"started_at":{"type":["string","null"],"description":"When the current (or last) sync run started, ISO-8601."},"heartbeat_at":{"type":["string","null"],"description":"Last progress tick of the current run, ISO-8601. A `status: \"syncing\"` connection whose heartbeat is far in the past is stalled, not busy."},"total_jobs":{"type":"number","description":"Jobs in the current or last run. `0` when none has run."},"completed_jobs":{"type":"number","description":"Of `total_jobs`, how many finished."},"failed_jobs":{"type":"number","description":"Of `total_jobs`, how many failed."},"eta_seconds":{"type":["number","null"],"description":"The worker's estimate of seconds remaining on the in-flight run."},"current_task":{"type":["string","null"],"description":"What the in-flight run is doing right now, e.g. the resource being paged."}},"required":["last_synced_at","last_error","started_at","heartbeat_at","total_jobs","completed_jobs","failed_jobs","eta_seconds","current_task"],"description":"State of the last (or in-flight) sync run for this connection. Always present on both kinds; every field reflects what the connector last recorded."},"RevokedConnection":{"type":"object","properties":{"revoked_keys":{"type":"number","description":"How many active API keys the revocation cut. `0` is a normal answer: the authorization was already revoked, or it belongs to a newer key generation than the one calling. The count can also exceed the keys minted under this one authorization — when it was your app's last live authorization on the profile, older key generations that no authorization names are cut with it."}},"required":["revoked_keys"]},"CustomerPayment":{"type":"object","properties":{"outcome_event_id":{"type":"string","format":"uuid","description":"The payment's `outcome_events.id`. Pass this back as the override's `outcome_event_id`. Same spelling `GET /api/v1/attribution/explore` uses."},"conversion_time":{"type":"string","example":"2026-08-15T10:00:00.000Z"},"value_amount_original":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"The amount as charged, in `currency` — pre-FX, not the reporting currency."},"currency":{"type":["string","null"],"description":"ISO-4217 of `value_amount_original`.","example":"CLP"},"external_source":{"type":"string","description":"Where the payment was ingested from (`stripe`, `mercadopago`, `fintoc` — a bank transfer read off a linked account — `gohighlevel`, `manual`, …), or `unknown`.","example":"stripe"},"is_first_payment":{"type":"boolean","description":"The EFFECTIVE flag, after any override — what LTV cohorts, the first-payment-vs-recurring revenue split and the CAC denominator use."},"manual_override":{"type":["boolean","null"],"description":"The operator's stored decision, or `null` for none. `null` is NOT `false`: `false` means someone said this is not a first payment, `null` means nobody has said anything and `is_first_payment` is derived (earliest payment, plus a 7-day grace window and same-invoice grouping)."}},"required":["outcome_event_id","conversion_time","value_amount_original","currency","external_source","is_first_payment","manual_override"]},"FirstPaymentOverrideResult":{"type":"object","properties":{"outcome_event_id":{"type":"string","format":"uuid"},"manual_override":{"type":["boolean","null"],"description":"The override now stored — echoes the request."},"changed":{"type":"boolean","description":"False when this call wrote nothing because the payment already carried the same override. Retries are safe and answer 200 either way."}},"required":["outcome_event_id","manual_override","changed"]},"MergeProposal":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["pending","approved","rejected","snoozed","superseded"],"description":"`superseded` means a later scan replaced this proposal with a better one; it is never returned for a decision you made."},"match_type":{"type":"string","example":"exact_email"},"match_value":{"type":["string","null"]},"confidence_band":{"type":"string","example":"high"},"evidence":{"$ref":"#/components/schemas/MergeProposalEvidence"},"snoozed_until":{"type":["string","null"],"description":"Set only while `status` is `snoozed`."},"resolved_at":{"type":["string","null"]},"created_at":{"type":"string"},"survivor":{"$ref":"#/components/schemas/MergeProposalCandidate"},"merged":{"allOf":[{"$ref":"#/components/schemas/MergeProposalCandidate"},{"description":"The customer that would be merged away — its events move to the survivor."}]}},"required":["id","status","match_type","match_value","confidence_band","evidence","snoozed_until","resolved_at","created_at","survivor","merged"]},"MergeProposalEvidence":{"type":"object","properties":{"events_survivor":{"type":["number","null"]},"events_merged":{"type":["number","null"]},"revenue_survivor_cents":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"revenue_merged_cents":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["events_survivor","events_merged","revenue_survivor_cents","revenue_merged_cents"],"description":"The counting evidence stored when the proposal was raised — what was true THEN, not now (use the preview for that).\n\n`events_*` count `public.touches`, not raw events: the source moved off the 30-day ephemeral event buffer so the number stops collapsing as events age out. The KEYS keep their old names because they are read back out of a stored JSON column — renaming them would make every proposal raised before the change read as zero."},"MergeProposalCandidate":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"`customer_profiles.id`."},"first_name":{"type":["string","null"]},"last_name":{"type":["string","null"]},"primary_email":{"type":["string","null"]},"primary_phone":{"type":["string","null"]},"last_seen_at":{"type":["string","null"]},"created_at":{"type":"string"},"identifier_sources":{"type":"array","items":{"type":"string"},"description":"The DISTINCT `id_type` values this customer carries (`email`, `phone`, `ghl_contact_id`, `anonymous_id`, …), sorted. Where the identity came from; the values themselves are never included.","example":["email","ghl_contact_id"]}},"required":["id","first_name","last_name","primary_email","primary_phone","last_seen_at","created_at","identifier_sources"],"description":"The customer that would REMAIN. Override it when deciding, if the other is better."},"MergeProposalCounts":{"type":"object","properties":{"pending":{"type":"number"},"snoozed":{"type":"number"},"rejected":{"type":"number"}},"required":["pending","snoozed","rejected"]},"MergeProposalScanResult":{"type":"object","properties":{"inserted":{"type":"number"},"superseded":{"type":"number"},"resurfaced":{"type":"number"}},"required":["inserted","superseded","resurfaced"]},"MergeProposalPreview":{"type":"object","properties":{"survivor_profile_id":{"type":"string","format":"uuid"},"merged_profile_id":{"type":"string","format":"uuid"},"event_count":{"type":"number","description":"`outcome_events` rows that would move."},"identifier_count":{"type":"number"},"touch_count":{"type":"number","description":"`public.touches` rows that would move."},"conversion_count":{"type":"number"},"cash_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Sum of the merged side's CASH conversion value, in the reporting currency."},"lead_coalesce":{"type":"boolean","description":"True when BOTH customers carry a `lead_created` — the merge collapses two leads into one, so the profile's lead count drops by one."},"email_collision":{"type":"boolean","description":"Both sides have a primary email and they differ. The survivor's wins."},"phone_collision":{"type":"boolean"}},"required":["survivor_profile_id","merged_profile_id","event_count","identifier_count","touch_count","conversion_count","cash_revenue","lead_coalesce","email_collision","phone_collision"]},"MergeProposalDecisionResult":{"type":"object","properties":{"proposal_id":{"type":"string","format":"uuid"},"decision":{"type":"string","enum":["merge","dismiss","snooze"]},"status":{"type":"string","enum":["pending","approved","rejected","snoozed","superseded"],"description":"The proposal's status AFTER this call — the one it already held when `changed` is false."},"changed":{"type":"boolean","description":"False when the proposal was already decided and this call wrote nothing. Retries are safe and answer 200 either way."},"snoozed_until":{"type":["string","null"]},"recompute_enqueued":{"type":"boolean","description":"Whether an attribution recompute was enqueued. True only for a `merge` that actually merged — nothing else here moves attribution credit."}},"required":["proposal_id","decision","status","changed","snoozed_until","recompute_enqueued"]},"IdentityRepairScan":{"type":"object","properties":{"total_profiles":{"type":"number"},"clean_profiles":{"type":"number"},"missing_primary_email":{"type":"number"},"missing_primary_phone":{"type":"number"},"duplicate_email_groups":{"type":"number"},"duplicate_phone_groups":{"type":"number"},"auto_repairable_count":{"type":"number","description":"Backfills the auto pass would apply. Merges are NOT counted here."},"auto_merge_count":{"type":"number","description":"Profiles with an unambiguous merge partner. Reported, not actionable here — merges go through the proposal queue, not the bulk repair."},"profiles":{"type":"array","items":{"$ref":"#/components/schemas/IdentityProfileSummary"}},"page":{"type":"number"},"page_size":{"type":"number"},"has_next_page":{"type":"boolean"}},"required":["total_profiles","clean_profiles","missing_primary_email","missing_primary_phone","duplicate_email_groups","duplicate_phone_groups","auto_repairable_count","auto_merge_count","profiles","page","page_size","has_next_page"]},"IdentityProfileSummary":{"type":"object","properties":{"profile_id":{"type":"string","format":"uuid","description":"`customer_profiles.id` — the CUSTOMER, not the Atribu profile. Named this way by the underlying scan and kept verbatim rather than renamed in one of three places."},"display_name":{"type":"string"},"primary_email":{"type":["string","null"]},"primary_phone":{"type":["string","null"]},"suggested_email":{"type":["string","null"],"description":"What a backfill would write — recovered from identifiers or CRM raw data."},"suggested_phone":{"type":["string","null"]},"identifier_count":{"type":"number"},"missing_primary_email":{"type":"boolean"},"missing_primary_phone":{"type":"boolean"},"primary_source":{"type":["string","null"]},"has_merge_candidate":{"type":"boolean"},"social_only_identifiers":{"type":"array","items":{"type":"string"},"description":"Non-empty only for a \"ghost\": no email AND no phone, reachable only through a messaging surface (`instagram`, `manychat`, `whatsapp`).","example":["instagram"]}},"required":["profile_id","display_name","primary_email","primary_phone","suggested_email","suggested_phone","identifier_count","missing_primary_email","missing_primary_phone","primary_source","has_merge_candidate","social_only_identifiers"]},"IdentityRepairPreview":{"type":"object","properties":{"operations":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","enum":["backfill_primary_email","backfill_primary_phone","merge_profiles"]},"profile_id":{"type":"string","format":"uuid","description":"The customer being CHANGED — for a merge, the one merged away."},"display_name":{"type":["string","null"]},"current_value":{"type":["string","null"]},"next_value":{"type":"string"},"will_apply":{"type":"boolean","description":"False when the operation is a no-op or no longer valid; `reason` says which."},"reason":{"type":"string"},"target_profile_id":{"type":"string","format":"uuid"},"target_display_name":{"type":["string","null"]}},"required":["type","profile_id","display_name","current_value","next_value","will_apply","reason"]}},"total_requested":{"type":"number"},"total_applicable":{"type":"number"}},"required":["operations","total_requested","total_applicable"]},"IdentityRepairApply":{"type":"object","properties":{"total_requested":{"type":"number"},"total_applied":{"type":"number"},"merge_operations_applied":{"type":"number"},"recompute_required":{"type":"boolean"},"recompute_enqueued":{"type":"boolean"},"skipped":{"type":"array","items":{"type":"object","properties":{"profile_id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["backfill_primary_email","backfill_primary_phone","merge_profiles"]},"reason":{"type":"string"}},"required":["profile_id","type","reason"]}}},"required":["total_requested","total_applied","merge_operations_applied","recompute_required","recompute_enqueued","skipped"],"description":"A partial success is the NORMAL outcome, not an error: an operation whose precondition moved since the preview lands in `skipped` with the reason, and the rest still apply. That is what makes the whole call safe to retry."},"IdentityRepairOperation":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["backfill_primary_email","backfill_primary_phone"]},"profile_id":{"type":"string","format":"uuid"},"value":{"type":"string"}},"required":["type","profile_id","value"]},{"type":"object","properties":{"type":{"type":"string","enum":["merge_profiles"]},"survivor_profile_id":{"type":"string","format":"uuid"},"merged_profile_id":{"type":"string","format":"uuid"},"match_type":{"type":"string","enum":["exact_email","exact_phone","manual"]},"match_value":{"type":["string","null"]},"reason":{"type":["string","null"]}},"required":["type","survivor_profile_id","merged_profile_id","match_type","match_value"]}],"description":"One repair. There is NO actor field: the merge audit row records who did it, and that comes from the authenticated principal (a session's user, or null for an API key). Accepting it in the body would let any caller write any user's id into the audit log."},"MergeHistoryEntry":{"type":"object","properties":{"audit_log_id":{"type":"string","format":"uuid","description":"Identifies the MERGE, not the customer. It is what an undo is keyed on."},"survivor_profile_id":{"type":"string","format":"uuid"},"merged_profile_id":{"type":"string","format":"uuid"},"repair_reason":{"type":["string","null"],"example":"merge_proposal"},"matched_identity_type":{"type":["string","null"]},"matched_identity_value":{"type":["string","null"]},"merged_by_user_id":{"type":["string","null"],"description":"Who merged, or null for an automated repair."},"created_at":{"type":"string"},"merged_first_name":{"type":["string","null"]},"merged_last_name":{"type":["string","null"]},"merged_primary_email":{"type":["string","null"]},"merged_primary_phone":{"type":["string","null"]},"is_currently_merged":{"type":"boolean","description":"False once the merge was undone. The row stays as the audit record."},"moved_event_count":{"type":"number"},"moved_identifier_count":{"type":"number"}},"required":["audit_log_id","survivor_profile_id","merged_profile_id","repair_reason","matched_identity_type","matched_identity_value","merged_by_user_id","created_at","merged_first_name","merged_last_name","merged_primary_email","merged_primary_phone","is_currently_merged","moved_event_count","moved_identifier_count"]},"UndoMergeResult":{"type":"object","properties":{"survivor_profile_id":{"type":"string","format":"uuid"},"merged_profile_id":{"type":"string","format":"uuid","description":"The customer that has been restored as a separate person."},"reverses_audit_log_id":{"type":"string","format":"uuid"},"unmerge_audit_log_id":{"type":["string","null"],"format":"uuid","description":"The audit row the UNDO itself wrote. The original merge's row stays as the record."},"lead_restored":{"type":"boolean","description":"True when the merged customer's `lead_created` event was put back — the mirror of `lead_coalesce` on the preview."},"recompute_enqueued":{"type":"boolean","description":"Whether the re-attribution this undo requires reached the queue. False is reported, never fatal: the unmerge has already committed and the hourly staleness sweep will pick it up."}},"required":["survivor_profile_id","merged_profile_id","reverses_audit_log_id","unmerge_audit_log_id","lead_restored","recompute_enqueued"]},"CommerceProduct":{"type":"object","properties":{"product_id":{"type":"string"},"provider":{"type":["string","null"],"example":"shopify","description":"null for a product not synced from a store."},"external_id":{"type":["string","null"],"example":"gid://shopify/Product/1"},"name":{"type":"string"},"description":{"type":["string","null"]},"slug":{"type":["string","null"]},"image_url":{"type":["string","null"]},"active":{"type":["boolean","null"],"description":"`false` for a product the store deactivated or deleted. Deactivated products ARE returned: a mirror has to learn about the removal, and omitting them makes a deleted product indistinguishable from one that simply stopped changing."},"currency":{"type":["string","null"]},"updated_at":{"type":"string","description":"When Atribu last wrote this row — the `updated_since` clock and the first half of the keyset cursor. Not the store's own timestamp; see `external_updated_at`."},"external_updated_at":{"type":["string","null"]},"variants":{"type":"array","items":{"$ref":"#/components/schemas/CommerceVariant"}}},"required":["product_id","provider","external_id","name","description","slug","image_url","active","currency","updated_at","external_updated_at","variants"]},"CommerceVariant":{"type":"object","properties":{"variant_id":{"type":"string","description":"Atribu's own id, stable across syncs."},"external_id":{"type":["string","null"],"example":"gid://shopify/ProductVariant/1"},"sku":{"type":["string","null"]},"barcode":{"type":["string","null"]},"title":{"type":["string","null"]},"price":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"currency":{"type":["string","null"],"description":"ISO-4217. `null` on rows synced before the catalogue sync began capturing the shop's currency — reported as unknown rather than defaulted, because a price with the wrong unit is worse than a price with none."},"inventory_quantity":{"type":["integer","null"],"description":"Units available across every location; null when untracked."},"inventory_state":{"type":["string","null"],"enum":["in_stock","low_stock","out_of_stock","discontinued",null],"description":"The stock band Atribu derives. `low_stock` is Atribu's threshold (5 or fewer available), not something the store reports; `discontinued` means the product left ACTIVE status or was deleted. `null` means the band is unknown — which is what an UNTRACKED item is, and is not the same as `in_stock`."},"inventory_policy":{"type":["string","null"],"description":"The store's oversell policy, verbatim (Shopify: DENY/CONTINUE)."},"tracked":{"type":["boolean","null"]},"updated_at":{"type":"string","description":"When Atribu last wrote this row."}},"required":["variant_id","external_id","sku","barcode","title","price","currency","inventory_quantity","inventory_state","inventory_policy","tracked","updated_at"]},"CommerceOrder":{"type":"object","properties":{"order_id":{"type":"string","example":"7401443590363","description":"The store's own order id."},"provider":{"type":"string","example":"shopify"},"order_ref":{"type":["string","null"],"example":"#1003","description":"The number the shopper sees on their confirmation, where the store sent one. Frequently null — it reaches Atribu on the order-created webhook and never on a bulk re-sync."},"status":{"type":"string","enum":["placed","paid","fulfilled","delivered","cancelled"],"description":"Atribu's single answer to 'where is my order'. Coarser than the store's own two status fields, which are carried alongside unmodified. `delivered` outranks `fulfilled`: a store leaves `fulfillment_status` at `fulfilled` after delivery."},"financial_status":{"type":["string","null"],"example":"paid"},"fulfillment_status":{"type":["string","null"],"example":"fulfilled"},"ordered_at":{"type":"string"},"updated_at":{"type":"string"},"value_amount":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"The captured total where there is one, else the ordered total."},"currency":{"type":["string","null"]}},"required":["order_id","provider","order_ref","status","financial_status","fulfillment_status","ordered_at","updated_at","value_amount","currency"]},"ConversationsListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Conversation"}},"pagination":{"type":"object","properties":{"has_next":{"type":"boolean"},"cursor":{"type":"string"}},"required":["has_next"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","pagination","meta"]},"Conversation":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"channel":{"type":"string","enum":["whatsapp","instagram","email"]},"external_user_id":{"type":"string","description":"The phone number (WhatsApp), IGSID (Instagram) or Gmail thread id (email)."},"status":{"type":"string"},"service_window_expires_at":{"type":["string","null"]},"last_message_at":{"type":["string","null"]},"last_inbound_at":{"type":["string","null"]},"inbound_count":{"type":"number"},"outbound_count":{"type":"number"},"customer_profile_id":{"type":["string","null"],"format":"uuid"},"contact_name":{"type":["string","null"]},"contact_phone":{"type":["string","null"]},"linked_customer_is_payer":{"type":"boolean","description":"True when the linked customer is a real payer — has an email/phone or a cash conversion — as opposed to a thin DM-only profile."},"linked_customer_name":{"type":["string","null"]},"ctwa_clid":{"type":["string","null"]},"referral_source_type":{"type":["string","null"]},"referral_source_id":{"type":["string","null"]},"referral_headline":{"type":["string","null"]},"referral_body":{"type":["string","null"]},"referral_media_type":{"type":["string","null"]},"referral_media_url":{"type":["string","null"]},"referral_ads_context":{"$ref":"#/components/schemas/ConversationAdsContext"},"ad_name":{"type":["string","null"]},"campaign_name":{"type":["string","null"]},"human_handoff_at":{"type":["string","null"]},"ig_username":{"type":["string","null"]},"profile_pic_url":{"type":["string","null"]},"email_subject":{"type":["string","null"]},"email_correspondent":{"type":["string","null"]},"last_message":{"type":["object","null"],"properties":{"text":{"type":["string","null"]},"type":{"type":"string"},"direction":{"type":"string"}},"required":["text","type","direction"]}},"required":["id","channel","external_user_id","status","service_window_expires_at","last_message_at","last_inbound_at","inbound_count","outbound_count","customer_profile_id","contact_name","contact_phone","linked_customer_is_payer","linked_customer_name","ctwa_clid","referral_source_type","referral_source_id","referral_headline","referral_body","referral_media_type","referral_media_url","referral_ads_context","ad_name","campaign_name","human_handoff_at","ig_username","profile_pic_url","email_subject","email_correspondent","last_message"]},"ConversationAdsContext":{"type":["object","null"],"properties":{"ad_title":{"type":["string","null"]},"photo_url":{"type":["string","null"]},"video_url":{"type":["string","null"]},"ref":{"type":["string","null"]},"source":{"type":["string","null"]}}},"ConversationMessagesResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ConversationMessage"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"ConversationMessage":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"direction":{"type":"string","enum":["inbound","outbound"]},"message_type":{"type":"string"},"text_body":{"type":["string","null"]},"caption":{"type":["string","null"]},"status":{"type":["string","null"]},"is_ai_generated":{"type":"boolean"},"ai_model":{"type":["string","null"]},"external_message_id":{"type":["string","null"]},"media_id":{"type":["string","null"]},"media_url":{"type":["string","null"]},"media_mime_type":{"type":["string","null"]},"channel":{"type":"string","enum":["whatsapp","instagram","email"]},"channel_data":{"type":"object","additionalProperties":{}},"message_timestamp":{"type":"string"},"created_at":{"type":"string"}},"required":["id","direction","message_type","text_body","caption","status","is_ai_generated","ai_model","external_message_id","media_id","media_url","media_mime_type","channel","channel_data","message_timestamp","created_at"]},"ConversationOrganicMatchesResponse":{"type":"object","properties":{"data":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ConversationOrganicMatch"}},"has_embeddings":{"type":"boolean"},"has_substantive":{"type":"boolean"}},"required":["data","has_embeddings","has_substantive"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"ConversationOrganicMatch":{"type":"object","properties":{"result_type":{"type":"string","enum":["post","ad"]},"result_id":{"type":"string","description":"post_id (organic) | ads.platform_id (ad)."},"platform":{"type":"string","enum":["instagram","facebook","meta_ads"]},"title":{"type":["string","null"]},"caption":{"type":["string","null"]},"thumbnail_url":{"type":["string","null"]},"media_url":{"type":["string","null"]},"permalink":{"type":["string","null"]},"media_type":{"type":["string","null"]},"media_product_type":{"type":["string","null"]},"ad_platform_id":{"type":["string","null"]},"ad_status":{"type":["string","null"]},"similarity_score":{"type":"number"},"embedding_kind":{"type":"string","enum":["caption","caption_video","creative_multimodal","creative_copy"]},"is_inferred_source":{"type":"boolean","description":"True when Stage 5c inferred this ad as the conversation's likely source (`attribution_mode = native_conversation_model`). Decision-support only, never a deterministic claim."},"inferred_confidence_band":{"type":"string","enum":["high","medium","low"]},"inferred_probability":{"type":"number"},"inferred_intent":{"type":"string"},"inferred_ad_angle":{"type":"string"}},"required":["result_type","result_id","platform","title","caption","thumbnail_url","media_url","permalink","media_type","media_product_type","ad_platform_id","ad_status","similarity_score","embedding_kind"]},"ConversationLabelsResponse":{"type":"object","properties":{"data":{"type":"object","properties":{"labels":{"type":"array","items":{"$ref":"#/components/schemas/GmailLabel"}}},"required":["labels"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"GmailLabel":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"}},"required":["id","name","type"]},"ConversationReviewProposalsResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ReviewProposal"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"ReviewProposal":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"extraction_method":{"type":"string","enum":["regex_strict","regex_forgiving","llm_v1","username_match"]},"candidate_email":{"type":["string","null"]},"candidate_phone":{"type":["string","null"]},"candidate_first_name":{"type":["string","null"]},"candidate_last_name":{"type":["string","null"]},"proposed_customer_profile_id":{"type":["string","null"],"format":"uuid"},"confidence_band":{"type":"string","enum":["high","medium","low"]},"evidence":{"type":["object","null"],"additionalProperties":{}},"status":{"type":"string"},"created_at":{"type":"string"},"proposed_customer":{"$ref":"#/components/schemas/ReviewProposalCustomer"}},"required":["id","extraction_method","candidate_email","candidate_phone","candidate_first_name","candidate_last_name","proposed_customer_profile_id","confidence_band","evidence","status","created_at","proposed_customer"]},"ReviewProposalCustomer":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"first_name":{"type":["string","null"]},"last_name":{"type":["string","null"]},"primary_email":{"type":["string","null"]},"primary_phone":{"type":["string","null"]}},"required":["id","first_name","last_name","primary_email","primary_phone"]},"PendingReviewsCountResponse":{"type":"object","properties":{"data":{"type":"object","properties":{"count":{"type":"number"},"first_conversation_id":{"type":["string","null"],"format":"uuid"}},"required":["count","first_conversation_id"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"UnlinkedInstagramPaymentsResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/UnlinkedInstagramPayment"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"UnlinkedInstagramPayment":{"type":"object","properties":{"conversion_id":{"type":"string","format":"uuid"},"customer_profile_id":{"type":"string","format":"uuid"},"payer_name":{"type":["string","null"]},"payer_email":{"type":["string","null"]},"amount":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"currency":{"type":["string","null"]},"paid_at":{"type":"string"},"provider":{"type":["string","null"]}},"required":["conversion_id","customer_profile_id","payer_name","payer_email","amount","currency","paid_at","provider"]},"InstagramConversationSearchResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/InstagramConversationCandidate"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"InstagramConversationCandidate":{"type":"object","properties":{"conversation_id":{"type":"string","format":"uuid"},"ig_username":{"type":["string","null"]},"external_user_id":{"type":"string"},"last_message_at":{"type":["string","null"]},"customer_profile_id":{"type":["string","null"],"format":"uuid"},"attribution_mode":{"type":["string","null"]},"entry_source":{"type":["string","null"]}},"required":["conversation_id","ig_username","external_user_id","last_message_at","customer_profile_id","attribution_mode","entry_source"]},"SendMessageResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConversationMessage"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"SaveDraftResponse":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"ApplyLabelsResponse":{"type":"object","properties":{"data":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"LinkCustomerResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/LinkCustomerResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"LinkCustomerResult":{"type":"object","properties":{"action":{"type":"string","enum":["merged","attached","unmerged","detached","needs_confirmation","noop"]},"reason":{"type":"string"},"floating":{"type":"string","format":"uuid"},"payer":{"type":"string","format":"uuid"},"survivor":{"type":"string","format":"uuid"},"bridge":{"type":"object","additionalProperties":{}}},"required":["action"]},"UnlinkCustomerResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/LinkCustomerResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"DecideReviewProposalResponse":{"type":"object","properties":{"data":{"type":"object","properties":{"status":{"type":"string","enum":["rejected","approved"]},"error":{"type":"string"},"current_status":{"type":"string"},"customer_profile_id":{"type":"string","format":"uuid"}}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"ResolveConversationUserResponse":{"type":"object","properties":{"data":{"type":["object","null"],"properties":{"igsid":{"type":"string"},"username":{"type":["string","null"]},"name":{"type":["string","null"]},"profile_pic":{"type":["string","null"]},"is_user_follow_business":{"type":["boolean","null"]},"cached_at":{"type":["string","null"]}},"required":["igsid","username","name","profile_pic","is_user_follow_business","cached_at"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"PendingAction":{"type":"object","properties":{"id":{"type":"string","description":"The `handoff_id` the minting call returned. Not the handle the URL carries — that is the human's capability, this is the agent's.","example":"0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21"},"kind":{"type":"string","enum":["connect","pick","sign_dpa","checkout","approve"],"description":"What the human is being asked to do. `connect` an OAuth consent, `pick` a choice among the assets a consent exposed, `sign_dpa` a Data Processing Addendum, `checkout` a plan payment, `approve` a consequential write you asked to make.","example":"pick"},"status":{"type":"string","enum":["pending","completed","expired","cancelled","failed"],"description":"`pending` until the human finishes, then terminal. `expired` is DERIVED at read time from `expires_at` — polling an expired hand-off answers 200 with this status, never 404, so you can tell 'mint a fresh one' from 'wrong id'.","example":"pending"},"url":{"type":["string","null"],"description":"The URL to hand to the human. Present ONLY while `status` is `pending`; null once the hand-off is terminal, because a spent capability is not handed back out. The page is session-less — whoever holds this link can complete it, signed in or not, on a phone.","example":"https://www.atribu.app/h/9tQ2mB1x…"},"expires_at":{"type":"string","description":"When the URL stops working. 45 minutes from the mint unless the kind says otherwise — the same budget Atribu's consumer connect detour ships against.","example":"2026-09-05T10:45:00.000Z"},"created_at":{"type":"string","example":"2026-09-05T10:00:00.000Z"},"completed_at":{"type":["string","null"],"description":"When the hand-off settled, in any terminal status. Null while pending."},"result":{"type":["object","null"],"additionalProperties":{},"description":"Kind-specific output, written when the hand-off settles. Carries `completed_by` (the Atribu user id) when a signed-in person finished it, and null there when an anonymous link holder did. Null while pending."}},"required":["id","kind","status","url","expires_at","created_at","completed_at","result"],"description":"One hand-off: a step of the golden path only a human can take. Mint it through the route for its kind, hand `url` over, poll this endpoint until `status` is no longer `pending`, then continue."},"LegalAcceptance":{"type":"object","properties":{"document":{"type":"string","enum":["dpa","baa"],"description":"Which agreement was accepted. `dpa` writes only the DPA columns; `baa` is the combined DPA & BAA click-wrap that Healthcare Mode requires and writes both.","example":"dpa"},"document_version":{"type":"string","description":"The version stamp actually recorded — one of the shipped texts, never the raw string a caller sent.","example":"2026-09-02-v1"},"accepted_at":{"type":"string","example":"2026-09-05T10:00:00.000Z"},"already_accepted":{"type":"boolean","description":"True when this document was ALREADY accepted and nothing was written — the call is idempotent at the document grain, so a reload never collects a second signature and never moves the original timestamp."}},"required":["document","document_version","accepted_at","already_accepted"]},"LegalAcceptRequest":{"type":"object","properties":{"document_version":{"type":"string","description":"Which shipped text you displayed. Allowlisted — an unknown value is a 422. Omit to accept the document's current version.","example":"2026-09-02-v1"}}},"CheckoutSessionRequest":{"type":"object","properties":{"plan":{"type":"string","enum":["growth","agency"],"description":"The plan to buy. Free has nothing to sell and Enterprise is negotiated, so neither is accepted here — `GET /api/v1/workspaces/{workspaceId}/subscription`'s `upgrade_available` lists exactly what this workspace can be sold.","example":"growth"},"interval":{"type":"string","enum":["monthly","annual"],"description":"Billing cadence. Defaults to `monthly` — an agent asking for \"the Growth plan\" has expressed a complete intent, and requiring a cadence would turn a one-argument hand-off into a two-argument one for no gain.","example":"monthly"}},"required":["plan"]},"IgCompetitorSource":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["handle","hashtag"]},"value":{"type":"string","description":"Normalised: lowercased, with a leading `@` or `#` stripped.","example":"nike"},"display_label":{"type":["string","null"]},"enabled":{"type":"boolean"},"last_synced_at":{"type":["string","null"]},"last_sync_error":{"type":["string","null"]},"created_at":{"type":"string"},"media_count":{"type":["number","null"],"description":"Distinct media captured on this source's most recent snapshot day.\n\n**`null` is not zero.** The count is a paged read of a snapshot-shaped table; when it cannot be assembled the field is `null` so a stalled sync is distinguishable from a handle that has never returned a post. Rendering it as `0` is exactly the bug #748 fixed."}},"required":["id","kind","value","display_label","enabled","last_synced_at","last_sync_error","created_at","media_count"]},"IgCompetitorMedia":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ig_media_id":{"type":"string"},"owner_username":{"type":["string","null"]},"media_type":{"type":["string","null"]},"media_product_type":{"type":["string","null"]},"caption":{"type":["string","null"]},"permalink":{"type":["string","null"]},"thumbnail_url":{"type":["string","null"]},"posted_at":{"type":["string","null"]},"like_count":{"type":["number","null"]},"comments_count":{"type":["number","null"]},"captured_on":{"type":"string","description":"The snapshot day, `YYYY-MM-DD`."}},"required":["id","ig_media_id","owner_username","media_type","media_product_type","caption","permalink","thumbnail_url","posted_at","like_count","comments_count","captured_on"]},"IgShortLink":{"type":"object","properties":{"slug":{"type":"string"},"ig_media_id":{"type":["string","null"]},"destination_url":{"type":["string","null"]},"utm_source":{"type":"string"},"utm_medium":{"type":"string"},"utm_campaign":{"type":["string","null"]},"utm_content":{"type":["string","null"]},"click_count":{"type":"number"},"last_clicked_at":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["slug","ig_media_id","destination_url","utm_source","utm_medium","utm_campaign","utm_content","click_count","last_clicked_at","created_at"]},"IgAccount":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"`instagram_accounts.id` — the BINDING, not the Instagram account itself."},"connection_id":{"type":"string","format":"uuid"},"ig_business_account_id":{"type":"string"},"ig_username":{"type":["string","null"]},"ig_display_name":{"type":["string","null"]},"profile_picture_url":{"type":["string","null"]},"followers_count":{"type":["number","null"]},"provider_subtype":{"type":"string","description":"`fb_login` (through a Facebook Page) or `ig_login` (Instagram Business Login). They differ in which id sends a DM and which scopes the grant carries.","example":"ig_login"},"page_id":{"type":["string","null"]},"webhook_subscribed":{"type":"boolean"},"token_expires_at":{"type":["string","null"]},"comment_to_dm_paused_until":{"type":["string","null"],"description":"Set while the comment-to-DM circuit breaker is tripped. Clear it with `POST /api/v1/instagram/accounts/{account_id}/resume-comment-to-dm`."},"comment_to_dm_last_tripped_at":{"type":["string","null"]},"is_automation_owner":{"type":"boolean","description":"One IG account can be connected from several workspaces; exactly one binding runs its automations. Disconnecting the owner promotes a surviving binding."},"created_at":{"type":"string"},"connection_status":{"type":"string","description":"From `data_connections`; `unknown` when that row could not be read."},"connection_last_error":{"type":["string","null"]}},"required":["id","connection_id","ig_business_account_id","ig_username","ig_display_name","profile_picture_url","followers_count","provider_subtype","page_id","webhook_subscribed","token_expires_at","comment_to_dm_paused_until","comment_to_dm_last_tripped_at","is_automation_owner","created_at","connection_status","connection_last_error"]},"IgAccountMedia":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ig_media_id":{"type":"string"},"media_type":{"type":["string","null"]},"caption":{"type":["string","null"]},"permalink":{"type":["string","null"]},"thumbnail_url":{"type":["string","null"]},"media_url":{"type":["string","null"]},"posted_at":{"type":["string","null"]},"like_count":{"type":["number","null"]},"comments_count":{"type":["number","null"]}},"required":["id","ig_media_id","media_type","caption","permalink","thumbnail_url","media_url","posted_at","like_count","comments_count"]},"IgCommentTrigger":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ig_account_id":{"type":"string","format":"uuid"},"keyword":{"type":"string"},"keyword_match_mode":{"type":"string","enum":["contains","exact","regex"]},"case_sensitive":{"type":"boolean"},"post_id_allowlist":{"type":["array","null"],"items":{"type":"string"},"description":"Media ids this trigger is restricted to; `null` means every post."},"opening_message":{"type":"string","description":"The DM sent when the trigger fires."},"public_comment_reply":{"type":["string","null"],"description":"Optional public reply left on the comment itself."},"agent_context_hint":{"type":["string","null"]},"enabled":{"type":"boolean"},"trigger_count":{"type":"number"},"last_triggered_at":{"type":["string","null"]},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","ig_account_id","keyword","keyword_match_mode","case_sensitive","post_id_allowlist","opening_message","public_comment_reply","agent_context_hint","enabled","trigger_count","last_triggered_at","created_at","updated_at"]},"IgCommentTriggerCreate":{"type":"object","properties":{"keyword":{"type":"string","minLength":1,"maxLength":500},"keyword_match_mode":{"type":"string","enum":["contains","exact","regex"]},"case_sensitive":{"type":"boolean"},"post_id_allowlist":{"type":["array","null"],"items":{"type":"string","minLength":1,"maxLength":64},"maxItems":200},"opening_message":{"type":"string","minLength":1,"maxLength":2000},"public_comment_reply":{"type":["string","null"],"maxLength":2000},"agent_context_hint":{"type":["string","null"],"maxLength":1000},"enabled":{"type":"boolean"}},"required":["keyword","opening_message"]},"IgCtmHealth":{"type":"object","properties":{"window_days":{"type":"number"},"severity":{"type":"string","enum":["ok","warn","critical","empty"]},"counts":{"$ref":"#/components/schemas/IgCtmHealthCounts"},"deterministic_count":{"type":"number"},"deterministic_pct":{"type":"number"}},"required":["window_days","severity","counts","deterministic_count","deterministic_pct"]},"IgCtmHealthCounts":{"type":"object","properties":{"total_ig_conversations":{"type":"number"},"exact_dm_ad_count":{"type":"number","description":"Attributed to an ad by an exact DM referral — deterministic."},"exact_follow_proxy_count":{"type":"number","description":"Attributed through a Follow Proxy click — deterministic."},"instagram_ad_referral_count":{"type":"number"},"native_conversation_model_count":{"type":"number"},"native_aggregate_only_count":{"type":"number"},"organic_or_unknown_count":{"type":"number"},"missing_email_count":{"type":"number"},"recent_webhook_failures":{"type":"number","description":"Non-zero forces `severity: \"critical\"` whatever the ratio."},"avg_modeled_confidence":{"type":["number","null"],"description":"Mean 0–1 confidence of the MODELED credit; null when nothing was modeled."}},"required":["total_ig_conversations","exact_dm_ad_count","exact_follow_proxy_count","instagram_ad_referral_count","native_conversation_model_count","native_aggregate_only_count","organic_or_unknown_count","missing_email_count","recent_webhook_failures","avg_modeled_confidence"]},"IgCtmInventory":{"type":"object","properties":{"ads":{"type":"array","items":{"$ref":"#/components/schemas/IgCtmInventoryAd"}},"summary":{"type":"object","properties":{"total":{"type":"number"},"mapped":{"type":"number"},"unmapped":{"type":"number"}},"required":["total","mapped","unmapped"]}},"required":["ads","summary"]},"IgCtmInventoryAd":{"type":"object","properties":{"ad_external_id":{"type":"string"},"ad_name":{"type":["string","null"]},"ad_status":{"type":["string","null"]},"adset_external_id":{"type":["string","null"]},"adset_name":{"type":["string","null"]},"campaign_external_id":{"type":["string","null"]},"campaign_name":{"type":["string","null"]},"optimization_goal":{"type":["string","null"]},"last_spend_date":{"type":["string","null"]},"mapped_in_manychat":{"type":"boolean","description":"False means this ad's conversations cannot be attributed deterministically — it is what makes the list actionable."}},"required":["ad_external_id","ad_name","ad_status","adset_external_id","adset_name","campaign_external_id","campaign_name","optimization_goal","last_spend_date","mapped_in_manychat"]},"IgFollowerAttribution":{"type":"object","properties":{"window_days":{"type":"number"},"total_follow_events":{"type":"number"},"rows":{"type":"array","items":{"$ref":"#/components/schemas/IgFollowerAttributionRow"}},"coverage_note":{"type":"string"}},"required":["window_days","total_follow_events","rows","coverage_note"]},"IgFollowerAttributionRow":{"type":"object","properties":{"candidate_ad_external_id":{"type":"string"},"ad_name":{"type":["string","null"]},"campaign_name":{"type":["string","null"]},"expected_followers":{"type":"number","description":"The SUM of per-event probabilities, not a count — a fractional value is correct."},"follow_event_count":{"type":"number"},"high_confidence_count":{"type":"number"},"medium_confidence_count":{"type":"number"},"low_confidence_count":{"type":"number"},"avg_probability":{"type":"number"}},"required":["candidate_ad_external_id","ad_name","campaign_name","expected_followers","follow_event_count","high_confidence_count","medium_confidence_count","low_confidence_count","avg_probability"]},"IgIdentityReviews":{"type":"object","properties":{"reviews":{"type":"array","items":{"$ref":"#/components/schemas/IgIdentityReview"}}},"required":["reviews"]},"IgIdentityReview":{"type":"object","properties":{"review_id":{"type":"string","format":"uuid"},"conversation_id":{"type":["string","null"],"format":"uuid"},"channel":{"type":["string","null"]},"external_user_id":{"type":["string","null"],"description":"The sender's IGSID."},"ig_username":{"type":["string","null"]},"candidate_email":{"type":["string","null"]},"candidate_phone":{"type":["string","null"]},"proposed_customer_profile_id":{"type":["string","null"],"format":"uuid"},"proposed_email":{"type":["string","null"]},"proposed_name":{"type":["string","null"]},"extraction_method":{"type":["string","null"]},"confidence_band":{"type":["string","null"]},"evidence":{"description":"The extractor's raw evidence blob — opaque, for display only."},"created_at":{"type":"string"}},"required":["review_id","conversation_id","channel","external_user_id","ig_username","candidate_email","candidate_phone","proposed_customer_profile_id","proposed_email","proposed_name","extraction_method","confidence_band","created_at"]},"IgIdentityReviewDecision":{"type":"object","properties":{"review_id":{"type":"string","format":"uuid"},"action":{"type":"string","enum":["approve","reject"]},"result":{}},"required":["review_id","action"]},"IgSyncEnqueued":{"type":"object","properties":{"queued":{"type":"boolean","enum":[true]},"jobs_enqueued":{"type":"number"}},"required":["queued","jobs_enqueued"]},"OverviewResponse":{"type":"object","properties":{"data":{"anyOf":[{"$ref":"#/components/schemas/OverviewTotals"},{"$ref":"#/components/schemas/OverviewDays"}]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"OverviewTotals":{"type":"object","properties":{"current":{"type":"object","properties":{"spend":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Ad spend in the window. `null`, never `\"0\"`, exactly when `spend_available` is `false` — a measured zero still arrives as `\"0\"`. Read `spend_available` for WHY it is null."},"spend_available":{"type":"boolean","description":"`false` when spend could not be scoped to the requested `filter[…]` set — either because a geo/device/browser/os dimension is not resolvable from an ad touch, or because the matching touches carry no ad ids at all. `spend`, `roas` and `clicks` are then `null`; this flag is the reason behind those nulls, not a substitute for reading them."},"clicks":{"type":["number","null"],"description":"Ad clicks — the head of the conversion path. `null`, never `0`, exactly when `spend_available` is `false`."},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"organic_direct_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"roas":{"type":["number","null"],"description":"Attributed revenue / spend. `null`, never `0`, exactly when `spend_available` is `false`. CAC, MER and NMER are not published — derive them from `spend` and the denominators here, and they inherit the null."},"outcomes":{"type":"number"},"attributed_outcomes":{"type":"number"},"attributed_cash_outcomes":{"type":"integer","description":"Attributed CASH outcomes only — the real CAC denominator."},"coverage_percent":{"type":"number"},"cash_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"cash_payments":{"type":"integer"},"first_payment_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Cash revenue from first payments only (the acquisition view)."},"first_payment_customers":{"type":"integer","description":"Distinct customers whose first payment landed in this window. A count(DISTINCT), so NOT summable across `grain=day` buckets."},"recurring_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Cash revenue from repeat payments."},"recurring_customers":{"type":"number","description":"Distinct repeat payers. count(DISTINCT); see `first_payment_customers`."},"refunds":{"type":"number","description":"COUNT of refund conversions."},"refund_amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Refunded AMOUNT. Money nets, counts do not — these are two numbers."},"disputes":{"type":"number","description":"COUNT of dispute conversions."},"dispute_amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Disputed AMOUNT."},"dispute_reversed_amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Amount of disputes later reversed in the merchant's favour. The engine exposes the amount but no matching count, so none is published."},"outcome_counts":{"type":"object","additionalProperties":{"type":"number"},"description":"Conversions per conversion type, e.g. `{ \"lead_created\": 25 }`. Free-form: the set of types is per-profile configuration, not an enum."}},"required":["spend","spend_available","clicks","revenue","organic_direct_revenue","roas","outcomes","attributed_outcomes","attributed_cash_outcomes","coverage_percent","cash_revenue","cash_payments","first_payment_revenue","first_payment_customers","recurring_revenue","recurring_customers","refunds","refund_amount","disputes","dispute_amount","dispute_reversed_amount","outcome_counts"]},"previous":{"type":"object","properties":{"spend":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Ad spend in the window. `null`, never `\"0\"`, exactly when `spend_available` is `false` — a measured zero still arrives as `\"0\"`. Read `spend_available` for WHY it is null."},"spend_available":{"type":"boolean","description":"`false` when spend could not be scoped to the requested `filter[…]` set — either because a geo/device/browser/os dimension is not resolvable from an ad touch, or because the matching touches carry no ad ids at all. `spend`, `roas` and `clicks` are then `null`; this flag is the reason behind those nulls, not a substitute for reading them."},"clicks":{"type":["number","null"],"description":"Ad clicks — the head of the conversion path. `null`, never `0`, exactly when `spend_available` is `false`."},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"organic_direct_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"roas":{"type":["number","null"],"description":"Attributed revenue / spend. `null`, never `0`, exactly when `spend_available` is `false`. CAC, MER and NMER are not published — derive them from `spend` and the denominators here, and they inherit the null."},"outcomes":{"type":"number"},"attributed_outcomes":{"type":"number"},"attributed_cash_outcomes":{"type":"integer","description":"Attributed CASH outcomes only — the real CAC denominator."},"coverage_percent":{"type":"number"},"cash_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"cash_payments":{"type":"integer"},"first_payment_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Cash revenue from first payments only (the acquisition view)."},"first_payment_customers":{"type":"integer","description":"Distinct customers whose first payment landed in this window. A count(DISTINCT), so NOT summable across `grain=day` buckets."},"recurring_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Cash revenue from repeat payments."},"recurring_customers":{"type":"number","description":"Distinct repeat payers. count(DISTINCT); see `first_payment_customers`."},"refunds":{"type":"number","description":"COUNT of refund conversions."},"refund_amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Refunded AMOUNT. Money nets, counts do not — these are two numbers."},"disputes":{"type":"number","description":"COUNT of dispute conversions."},"dispute_amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Disputed AMOUNT."},"dispute_reversed_amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Amount of disputes later reversed in the merchant's favour. The engine exposes the amount but no matching count, so none is published."},"outcome_counts":{"type":"object","additionalProperties":{"type":"number"},"description":"Conversions per conversion type, e.g. `{ \"lead_created\": 25 }`. Free-form: the set of types is per-profile configuration, not an enum."}},"required":["spend","spend_available","clicks","revenue","organic_direct_revenue","roas","outcomes","attributed_outcomes","attributed_cash_outcomes","coverage_percent","cash_revenue","cash_payments","first_payment_revenue","first_payment_customers","recurring_revenue","recurring_customers","refunds","refund_amount","disputes","dispute_amount","dispute_reversed_amount","outcome_counts"]}},"required":["current","previous"]},"OverviewDays":{"type":"object","properties":{"days":{"type":"array","items":{"type":"object","properties":{"spend":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Ad spend in the window. `null`, never `\"0\"`, exactly when `spend_available` is `false` — a measured zero still arrives as `\"0\"`. Read `spend_available` for WHY it is null."},"spend_available":{"type":"boolean","description":"`false` when spend could not be scoped to the requested `filter[…]` set — either because a geo/device/browser/os dimension is not resolvable from an ad touch, or because the matching touches carry no ad ids at all. `spend`, `roas` and `clicks` are then `null`; this flag is the reason behind those nulls, not a substitute for reading them."},"clicks":{"type":["number","null"],"description":"Ad clicks — the head of the conversion path. `null`, never `0`, exactly when `spend_available` is `false`."},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"organic_direct_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"roas":{"type":["number","null"],"description":"Attributed revenue / spend. `null`, never `0`, exactly when `spend_available` is `false`. CAC, MER and NMER are not published — derive them from `spend` and the denominators here, and they inherit the null."},"outcomes":{"type":"number"},"attributed_outcomes":{"type":"number"},"attributed_cash_outcomes":{"type":"integer","description":"Attributed CASH outcomes only — the real CAC denominator."},"coverage_percent":{"type":"number"},"cash_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"cash_payments":{"type":"integer"},"first_payment_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Cash revenue from first payments only (the acquisition view)."},"first_payment_customers":{"type":"integer","description":"Distinct customers whose first payment landed in this window. A count(DISTINCT), so NOT summable across `grain=day` buckets."},"recurring_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Cash revenue from repeat payments."},"recurring_customers":{"type":"number","description":"Distinct repeat payers. count(DISTINCT); see `first_payment_customers`."},"refunds":{"type":"number","description":"COUNT of refund conversions."},"refund_amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Refunded AMOUNT. Money nets, counts do not — these are two numbers."},"disputes":{"type":"number","description":"COUNT of dispute conversions."},"dispute_amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Disputed AMOUNT."},"dispute_reversed_amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Amount of disputes later reversed in the merchant's favour. The engine exposes the amount but no matching count, so none is published."},"outcome_counts":{"type":"object","additionalProperties":{"type":"number"},"description":"Conversions per conversion type, e.g. `{ \"lead_created\": 25 }`. Free-form: the set of types is per-profile configuration, not an enum."},"date":{"type":"string","description":"`YYYY-MM-DD`.","example":"2026-08-01"}},"required":["spend","spend_available","clicks","revenue","organic_direct_revenue","roas","outcomes","attributed_outcomes","attributed_cash_outcomes","coverage_percent","cash_revenue","cash_payments","first_payment_revenue","first_payment_customers","recurring_revenue","recurring_customers","refunds","refund_amount","disputes","dispute_amount","dispute_reversed_amount","outcome_counts","date"]},"description":"One zero-filled row per calendar day in the requested window."},"previous_days":{"type":"array","items":{"type":"object","properties":{"spend":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Ad spend in the window. `null`, never `\"0\"`, exactly when `spend_available` is `false` — a measured zero still arrives as `\"0\"`. Read `spend_available` for WHY it is null."},"spend_available":{"type":"boolean","description":"`false` when spend could not be scoped to the requested `filter[…]` set — either because a geo/device/browser/os dimension is not resolvable from an ad touch, or because the matching touches carry no ad ids at all. `spend`, `roas` and `clicks` are then `null`; this flag is the reason behind those nulls, not a substitute for reading them."},"clicks":{"type":["number","null"],"description":"Ad clicks — the head of the conversion path. `null`, never `0`, exactly when `spend_available` is `false`."},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"organic_direct_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"roas":{"type":["number","null"],"description":"Attributed revenue / spend. `null`, never `0`, exactly when `spend_available` is `false`. CAC, MER and NMER are not published — derive them from `spend` and the denominators here, and they inherit the null."},"outcomes":{"type":"number"},"attributed_outcomes":{"type":"number"},"attributed_cash_outcomes":{"type":"integer","description":"Attributed CASH outcomes only — the real CAC denominator."},"coverage_percent":{"type":"number"},"cash_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"cash_payments":{"type":"integer"},"first_payment_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Cash revenue from first payments only (the acquisition view)."},"first_payment_customers":{"type":"integer","description":"Distinct customers whose first payment landed in this window. A count(DISTINCT), so NOT summable across `grain=day` buckets."},"recurring_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Cash revenue from repeat payments."},"recurring_customers":{"type":"number","description":"Distinct repeat payers. count(DISTINCT); see `first_payment_customers`."},"refunds":{"type":"number","description":"COUNT of refund conversions."},"refund_amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Refunded AMOUNT. Money nets, counts do not — these are two numbers."},"disputes":{"type":"number","description":"COUNT of dispute conversions."},"dispute_amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Disputed AMOUNT."},"dispute_reversed_amount":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Amount of disputes later reversed in the merchant's favour. The engine exposes the amount but no matching count, so none is published."},"outcome_counts":{"type":"object","additionalProperties":{"type":"number"},"description":"Conversions per conversion type, e.g. `{ \"lead_created\": 25 }`. Free-form: the set of types is per-profile configuration, not an enum."},"date":{"type":"string","description":"`YYYY-MM-DD`.","example":"2026-08-01"}},"required":["spend","spend_available","clicks","revenue","organic_direct_revenue","roas","outcomes","attributed_outcomes","attributed_cash_outcomes","coverage_percent","cash_revenue","cash_payments","first_payment_revenue","first_payment_customers","recurring_revenue","recurring_customers","refunds","refund_amount","disputes","dispute_amount","dispute_reversed_amount","outcome_counts","date"]},"description":"The same series for the preceding window of equal length."}},"required":["days","previous_days"]},"LtvResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/LtvDashboard"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"LtvDashboard":{"type":["object","null"],"properties":{"currency":{"type":"string","example":"USD"},"period_from":{"type":"string"},"period_to":{"type":"string"},"cohort_depth_months":{"type":"number"},"kpis":{"$ref":"#/components/schemas/LtvKpis"},"cohort_retention":{"type":"array","items":{"$ref":"#/components/schemas/LtvCohortRetentionRow"}},"repeat_purchase_histogram":{"type":"array","items":{"$ref":"#/components/schemas/LtvRepeatPurchaseBucket"}},"ltv_curves":{"$ref":"#/components/schemas/LtvCurves"},"top_customers":{"type":"array","items":{"$ref":"#/components/schemas/LtvTopCustomer"}},"monthly_revenue":{"type":"array","items":{"$ref":"#/components/schemas/LtvMonthlyRevenue"}}},"required":["currency","period_from","period_to","cohort_depth_months","kpis","cohort_retention","repeat_purchase_histogram","ltv_curves","top_customers","monthly_revenue"],"description":"Null when the profile has no qualifying cohort data for the window."},"LtvKpis":{"type":"object","properties":{"total_cash":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Cash revenue from payments in the window (#472 — LTV is cash-only, per data-model.md)."},"total_payments":{"type":"integer"},"unique_customers":{"type":"number"},"avg_ltv":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"total_cash / unique_customers."},"repeat_rate_pct":{"type":["number","null"],"description":"% of customers with 2 or more payments. Null when there are no customers in the window."},"median_days_to_second":{"type":["number","null"],"description":"Median days between a customer's first and second payment. Null when no cohort has a repeat payer yet."}},"required":["total_cash","total_payments","unique_customers","avg_ltv","repeat_rate_pct","median_days_to_second"]},"LtvCohortRetentionRow":{"type":"object","properties":{"cohort_month":{"type":"string","example":"2026-03"},"customers":{"type":"number"},"retention_pct":{"type":"array","items":{"type":["number","null"]},"description":"% of the cohort still paying at month index N (0-based). Null past the cohort's observed depth."}},"required":["cohort_month","customers","retention_pct"]},"LtvRepeatPurchaseBucket":{"type":"object","properties":{"label":{"type":"string","example":"8-14 days"},"count":{"type":"number"}},"required":["label","count"]},"LtvCurves":{"type":"object","properties":{"avg":{"type":"array","items":{"$ref":"#/components/schemas/LtvCurvePoint"}},"best_cohort":{"type":"object","properties":{"cohort_month":{"type":["string","null"]},"points":{"type":"array","items":{"$ref":"#/components/schemas/LtvCurvePoint"}}},"required":["cohort_month","points"]}},"required":["avg","best_cohort"]},"LtvCurvePoint":{"type":"object","properties":{"m":{"type":"number","description":"Months since acquisition."},"ltv":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Cumulative cash LTV per customer at month m."}},"required":["m","ltv"]},"LtvTopCustomer":{"type":"object","properties":{"customer_profile_id":{"type":"string"},"name":{"type":["string","null"]},"email":{"type":["string","null"]},"cohort_month":{"type":"string"},"payments":{"type":"integer"},"ltv":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"last_payment_at":{"type":["string","null"]}},"required":["customer_profile_id","name","email","cohort_month","payments","ltv","last_payment_at"]},"LtvMonthlyRevenue":{"type":"object","properties":{"month":{"type":"string","example":"2026-03"},"first_payment_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Cash from a customer's FIRST payment — acquisition revenue."},"recurring_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Cash from a customer's 2nd+ payment in the month."},"first_payment_payments":{"type":"integer"},"recurring_payments":{"type":"integer"}},"required":["month","first_payment_revenue","recurring_revenue","first_payment_payments","recurring_payments"]},"CampaignConversionsResponse":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"conversion_id":{"type":"string"},"event_type":{"type":"string"},"event_time":{"type":"string"},"customer_profile_id":{"type":["string","null"]},"customer_key":{"type":["string","null"],"description":"CRM contact id (not name/email) — visible under campaigns:read alone."},"customer_name":{"type":["string","null"],"description":"Null unless the key also holds `customers:read`."},"customer_email":{"type":["string","null"],"description":"Null unless the key also holds `customers:read`."},"value_amount":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Full conversion value (all of it, not this campaign's share)."},"revenue_type":{"type":"string"},"credited_value":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"This campaign's credited share of `value_amount`."},"credited_weight":{"type":"number","description":"This campaign's share of the conversion, 0–1."},"touch_count":{"type":"number","description":"Touches on THIS campaign, out of `path_length` touches in the whole path."},"path_length":{"type":["number","null"]},"first_touch_time":{"type":["string","null"]},"last_touch_time":{"type":["string","null"]},"touch_channel":{"type":["string","null"]},"has_click_evidence":{"type":"boolean"},"is_inherited":{"type":"boolean","description":"True when the credit came via a bridged lead→cash touch, not a directly-tracked click — never counted in ROAS."}},"required":["conversion_id","event_type","event_time","customer_profile_id","customer_key","customer_name","customer_email","value_amount","revenue_type","credited_value","credited_weight","touch_count","path_length","first_touch_time","last_touch_time","touch_channel","has_click_evidence","is_inherited"]}},"meta":{"$ref":"#/components/schemas/CampaignConversionsMeta"}},"required":["data","meta"]},"CampaignConversionsMeta":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-03-01"},"date_to":{"type":"string","example":"2026-03-25"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]},"CashAttributionCoverage":{"type":["object","null"],"properties":{"model":{"type":"string"},"currency":{"type":["string","null"]},"total":{"type":"object","properties":{"count":{"type":"number"},"value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["count","value"]},"buckets":{"type":"object","properties":{"ad_attributed":{"type":"object","properties":{"count":{"type":"number"},"value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["count","value"]},"inherited":{"type":"object","properties":{"count":{"type":"number"},"value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["count","value"]},"dangling_ad":{"type":"object","properties":{"count":{"type":"number"},"value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["count","value"]},"organic_direct":{"type":"object","properties":{"count":{"type":"number"},"value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["count","value"]},"unattributed":{"type":"object","properties":{"count":{"type":"number"},"value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["count","value"]}},"required":["ad_attributed","inherited","dangling_ad","organic_direct","unattributed"]},"traceable_pct_by_count":{"type":"number"},"traceable_pct_by_value":{"type":"number"}},"required":["model","currency","total","buckets","traceable_pct_by_count","traceable_pct_by_value"]},"IngestEventAccepted":{"type":"object","properties":{"event_id":{"type":"string","description":"The idempotency key this event is filed under."},"status":{"type":"string","enum":["accepted"]},"outcome_event_id":{"type":["string","null"],"description":"The stored outcome event. The same id on every retry of the same key."},"action":{"type":"string","description":"What the write did: `inserted` on the first delivery, `updated` on a retry, `enriched`/`skipped_lead_exists` on the one-lead-per-customer path.","example":"inserted"},"customer_profile_id":{"type":["string","null"],"description":"The identity the sale was filed under, when one resolved. NULL is normal for an anon-only sale — it stays joinable by `anonymous_id`."},"conversion":{"$ref":"#/components/schemas/IngestEventConversion"}},"required":["event_id","status","outcome_event_id","action","customer_profile_id","conversion"]},"IngestEventConversion":{"type":"object","properties":{"matched":{"type":"boolean","description":"Whether a conversion goal on this profile lists `event_name` in its `source_event_names`. FALSE means the event was stored but will never become a conversion and will never be attributed — fix the event name, or create the goal via `POST /api/v1/goals`."},"conversion_key":{"type":["string","null"],"example":"payment_received"},"revenue_type":{"type":["string","null"],"description":"`cash` (counts toward ROAS), `pipeline`, or `gross`.","example":"cash"},"attribution_eligible":{"type":["boolean","null"]}},"required":["matched","conversion_key","revenue_type","attribution_eligible"]},"IngestEventBody":{"type":"object","properties":{"event_name":{"type":"string","pattern":"^[a-zA-Z0-9_]{1,255}$","description":"The outcome type. Use `payment_received` for a closed sale where money changed hands (revenue_type `cash`, the only kind that counts toward ROAS) and `closed_won` for a won-but-unpaid deal (revenue_type `pipeline`). Any other name is accepted, but unless a conversion goal lists it in `source_event_names` the event becomes no conversion at all — check `conversion.matched` in the response.","example":"payment_received"},"anonymous_id":{"type":"string","description":"The Atribu tracker's visitor id (`atribu_visitor_id` cookie / `atribu_anon_id`), when your checkout captured it. The most direct sale↔click link there is: it needs no email match."},"session_id":{"type":"string","description":"The tracker's session id, when captured. Links the sale to one specific visit."},"timestamp":{"type":"string","description":"When the sale closed (ISO-8601). Defaults to now. Send the ERP's own timestamp: it must be stable across retries, and it is what the attribution window is measured against.","example":"2026-08-25T14:32:10Z"},"properties":{"type":"object","additionalProperties":{},"description":"Free-form payload, stored verbatim on the outcome event. `value` (or `amount`) and `currency` are read out of it: `value` is the sale amount as a number or numeric string, `currency` an ISO-4217 code (defaults to USD). A `value` that is present but unparseable is a 400, never a silent zero."},"user_traits":{"$ref":"#/components/schemas/IngestEventUserTraits"},"utm":{"type":"object","properties":{"source":{"type":"string"},"medium":{"type":"string"},"campaign":{"type":"string","description":"For Meta, the campaign's numeric platform id."},"content":{"type":"string","description":"For Meta, the AD's numeric platform id."},"term":{"type":"string","description":"For Meta, the AD SET's numeric platform id."}},"description":"The marketing parameters the buyer arrived with, if your ERP recorded them. Used to build a synthetic touch for a sale that never had a web session."},"click_ids":{"type":"object","additionalProperties":{"type":"string"},"description":"Ad-platform click ids captured at the landing page. `fbclid`, `gclid`, `msclkid` and `ttclid` are joined against the click index to recover the originating session for up to 90 days; any other kind is stored for later.","example":{"fbclid":"IwAR3xK9…","gclid":"Cj0KCQjw…"}},"idempotency_key":{"type":"string","description":"The sale's own deal/order id. Retries with the same key land on the same outcome event — `external_id` is `api:<key>`. REQUIRED whenever `properties.value` is set: without it the key defaults to a fresh UUID, so a retried sale would be counted twice, and double-counted cash cannot be undone from the data. Also accepted as the `Idempotency-Key` header.","example":"deal_88213"}},"required":["event_name"],"description":"A server-side outcome event. The worked example is the sale-closed case: a dealer's ERP reporting a paid sale with the identity signals needed to join it back to the ad click.","example":{"event_name":"payment_received","timestamp":"2026-08-25T14:32:10Z","idempotency_key":"deal_88213","properties":{"value":18990000,"currency":"CLP","order_id":"deal_88213","model":"Corolla 2026"},"user_traits":{"email":"buyer@example.com","phone":"+56911112222","external_id":"erp_cust_4471"},"anonymous_id":"a1f3c9e2-5b60-4d21-9f0e-77b1c2d4e8aa","click_ids":{"fbclid":"IwAR3xK9…"}}},"IngestEventUserTraits":{"type":"object","properties":{"email":{"type":"string","description":"Buyer email. Normalised and resolved through the identity graph, which is how an off-site sale reaches the ad click captured on the website."},"phone":{"type":"string","description":"Buyer phone. Normalised to E.164 when parseable."},"first_name":{"type":"string"},"last_name":{"type":"string"},"external_id":{"type":"string","description":"YOUR customer id (the ERP/CRM primary key), stored as an `external_customer_id` identifier so repeat sales from the same buyer converge on one customer profile. On its own it is used for a READ-ONLY lookup: an identifier with no email or phone never creates a profile, because a profile minted from a foreign key alone can never be merged with the real one later."},"national_id":{"type":"string","description":"A government identity document number (e.g. a Chilean RUT) — the phone↔RUT↔email bridge a clinic's own practice-management software already holds. Normalised and stored as a `national_id` identifier, the same type MercadoPago's own `payer.identification` writes, so it is used as a match key alongside email and phone: unlike `external_id`, a `national_id` with no email or phone still creates a profile.","example":"14.146.609-7"},"country":{"type":"string","minLength":2,"maxLength":2,"description":"ISO 3166-1 alpha-2 hint for parsing `national_id`. Defaults to `CL` — the only country national_id parsing understands today.","example":"CL"}},"description":"Identity signals. At least one of these — or `anonymous_id`, or a click id — is what makes the sale attributable; a sale with none of them is recorded but can never be joined to an ad."},"CustomerImportResponse":{"type":"object","properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/CustomerImportRowResult"},"description":"One entry per request row, in the SAME order — degrade visibly, never drop."},"summary":{"type":"object","properties":{"total":{"type":"integer"},"created":{"type":"integer"},"matched":{"type":"integer"},"rejected":{"type":"integer"}},"required":["total","created","matched","rejected"]}},"required":["results","summary"]},"CustomerImportRowResult":{"type":"object","properties":{"index":{"type":"integer","description":"The row's position in the request's `rows` array."},"status":{"type":"string","enum":["created","matched","rejected"]},"customer_profile_id":{"type":["string","null"]},"reason":{"type":"string","description":"Present only on a `rejected` row — why it was rejected."}},"required":["index","status","customer_profile_id"]},"CustomerImportBody":{"type":"object","properties":{"country":{"type":"string","minLength":2,"maxLength":2,"description":"ISO 3166-1 alpha-2 hint for the WHOLE batch's `national_id` (and phone) parsing — one CSV is one clinic, one country. Defaults to `CL`.","example":"CL"},"rows":{"type":"array","items":{"$ref":"#/components/schemas/CustomerImportRow"},"minItems":1,"maxItems":1000,"description":"At most 1,000 rows per call."}},"required":["rows"],"description":"A batch of customer identities from the clinic's own records — a PMS export or a spreadsheet, not an event.","example":{"country":"CL","rows":[{"phone":"+56911112222","national_id":"14.146.609-7","first_name":"María","last_name":"Pérez"},{"email":"buyer@example.com","national_id":"20473794-0"}]}},"CustomerImportRow":{"type":"object","properties":{"phone":{"type":"string","description":"Normalised to E.164 when parseable."},"email":{"type":"string"},"national_id":{"type":"string","description":"A government identity document number (e.g. a Chilean RUT). Normalised exactly like `user_traits.national_id` on POST /api/v1/events."},"external_id":{"type":"string","description":"YOUR customer id (PMS/CRM primary key). Never enough on its own — a row with no phone, email or national_id is REJECTED, because a profile minted from a foreign key alone can never be merged with the real one later."},"first_name":{"type":"string"},"last_name":{"type":"string"}}},"WebpayIngestResult":{"anyOf":[{"$ref":"#/components/schemas/WebpayIngestAccepted"},{"$ref":"#/components/schemas/WebpayIngestIgnored"}]},"WebpayIngestAccepted":{"allOf":[{"$ref":"#/components/schemas/IngestEventAccepted"},{"type":"object","properties":{"attribution_carrier":{"type":["string","null"],"enum":["attribution_token","session_id","buy_order",null],"description":"Which field the visitor identity was recovered from. NULL means the passthrough carried nothing and the sale falls back to an email/phone match — check that your `Transaction.create()` really sent `getAttributionRef()` as `session_id`."},"anonymous_id":{"type":["string","null"],"description":"The visitor the sale was tied to, decoded from the carrier."}},"required":["attribution_carrier","anonymous_id"]}]},"WebpayIngestIgnored":{"type":"object","properties":{"event_id":{"type":"string"},"status":{"type":"string","enum":["ignored"]},"reason":{"type":"string"}},"required":["event_id","status","reason"]},"TypingIndicatorResponse":{"type":"object","properties":{"data":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"channel":{"type":"string","enum":["whatsapp"]},"to":{"type":"string"},"forwarded":{"type":"boolean","description":"true when the read receipt (plus the typing indicator, unless `indicator: \"read\"`) reached Meta. false when Meta refused the `message_id` (stale / already-read / re-registered number) — a knowing no-op, still a 200."},"indicator":{"type":"string","enum":["typing","read"],"description":"The mode that was applied, echoed back. Absent means the deployment predates this field and therefore behaved as `typing` — the only way to detect that a requested `read` silently showed a bubble."},"reason":{"type":"string","enum":["stale_message_id","message_id_conversation_mismatch"],"description":"Present only when `forwarded` is false. `stale_message_id` — Meta refused the wamid. `message_id_conversation_mismatch` — the wamid belongs to a different conversation than `to`, so it was never forwarded."},"requested_at":{"type":"string"}},"required":["connection_id","channel","to","forwarded","requested_at"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"MediaUploadResponse":{"type":"object","properties":{"data":{"type":"object","properties":{"media_id":{"type":"string","example":"1234567890123456"},"mime_type":{"type":"string","example":"video/mp4"},"file_size":{"type":"number","example":452012}},"required":["media_id","mime_type","file_size"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"WhatsAppMediaResolveResponse":{"type":"object","properties":{"data":{"type":"object","properties":{"url":{"type":"string","format":"uri","example":"https://media.atribu.app/conversation-media/…/….jpg?X-Amz-Signature=…","description":"Hosted, browser-fetchable URL (signed, ~7-day TTL)."},"mime_type":{"type":"string","example":"image/jpeg"},"expires_at":{"type":"string","example":"2026-07-08T04:45:18.000Z","description":"ISO timestamp at which `url` stops working."}},"required":["url","mime_type","expires_at"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"WhatsAppFlowSummary":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"status":{"type":"string","description":"DRAFT | PUBLISHED | DEPRECATED | BLOCKED | THROTTLED (Meta-owned enum)."},"categories":{"type":"array","items":{"type":"string"},"description":"Typed loosely on reads — Meta may add categories."},"validation_errors":{"type":"array","items":{"type":"object","additionalProperties":{}},"description":"Meta's flow.json validation findings, forwarded verbatim. Empty when valid."}},"required":["id","name","status","categories"]},"WhatsAppFlowDetail":{"allOf":[{"$ref":"#/components/schemas/WhatsAppFlowSummary"},{"type":"object","properties":{"json_version":{"type":"string"},"data_api_version":{"type":"string"},"endpoint_uri":{"type":"string","description":"Data-exchange endpoint (data_api flows only)."},"preview":{"type":["object","null"],"properties":{"preview_url":{"type":"string"},"expires_at":{"type":"string"}},"description":"Web preview URL for the flow (renders in a browser)."}}}]},"WhatsAppFlowAsset":{"type":"object","properties":{"name":{"type":"string"},"asset_type":{"type":"string","description":"FLOW_JSON today."},"download_url":{"type":"string","description":"Time-limited URL to download the asset bytes."}}},"WhatsAppChannelHealth":{"type":"object","properties":{"connectionId":{"type":"string"},"wabaId":{"type":"string"},"phoneNumberId":{"type":"string"},"canSend":{"type":["string","null"],"enum":["AVAILABLE","LIMITED","BLOCKED",null],"description":"Meta's overall `can_send_message`, or null when `health_status` could not be read. null means unknown, never healthy."},"tokenValid":{"type":"boolean"},"webhookSubscribed":{"type":"boolean"},"phone":{"type":"object","properties":{"displayPhoneNumber":{"type":["string","null"]},"verifiedName":{"type":["string","null"]},"qualityRating":{"type":["string","null"]},"messagingLimitTier":{"type":["string","null"]},"nameStatus":{"type":["string","null"]},"accountMode":{"type":["string","null"]},"codeVerificationStatus":{"type":["string","null"]},"throughputLevel":{"type":["string","null"]}},"required":["displayPhoneNumber","verifiedName","qualityRating","messagingLimitTier","nameStatus","accountMode","codeVerificationStatus","throughputLevel"]},"issues":{"type":"array","items":{"$ref":"#/components/schemas/WhatsAppHealthIssue"}},"refreshedAt":{"type":["string","null"],"description":"When this snapshot was last read from Meta (ISO). null on a never-refreshed account."},"stale":{"type":"boolean","description":"True when this snapshot was NOT confirmed against Meta just now — either it is older than the 6h TTL and a revalidation was throttled, or a revalidation ran and its live read failed, so these are last-good values. Treat it as \"the last thing we knew\", never as a verification. A successful live/forced refresh returns false."},"reconnectRequired":{"type":"boolean","description":"True when the channel needs re-authorization (invalid/expired token)."},"reconnectUrl":{"type":["string","null"],"description":"Where to send the user to reconnect the WhatsApp channel, or null when not required."}},"required":["connectionId","wabaId","phoneNumberId","canSend","tokenValid","webhookSubscribed","phone","issues","refreshedAt","stale","reconnectRequired","reconnectUrl"]},"WhatsAppHealthIssue":{"type":"object","properties":{"entityType":{"type":"string","description":"Meta entity_type (PHONE_NUMBER, MESSAGE_TEMPLATE, WABA, BUSINESS, APP) or a synthetic bucket (APP) for token/webhook issues."},"code":{"anyOf":[{"type":"number"},{"type":"string"},{"type":"null"}],"description":"Meta error_code (number), a synthetic string code, or null for advisories."},"description":{"type":"string"},"remediation":{"type":["string","null"],"description":"Meta's `possible_solution`, or Atribu's remediation for synthetic issues."},"severity":{"type":"string","enum":["critical","warning","info"]}},"required":["entityType","code","description","remediation","severity"]},"WhatsAppCallingSettings":{"type":"object","properties":{},"additionalProperties":{},"description":"Meta's `calling` object, passed through verbatim in both directions. Atribu holds no opinion about its contents — the SIP peer, the SRTP key exchange and the codec list are the caller's recipe. Documented as free-form on purpose: a schema enumerating Meta's fields would reject a field Meta shipped this morning."},"WhatsAppCallingBody":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"phone_number_id":{"type":"string","minLength":1,"maxLength":64},"settings":{"$ref":"#/components/schemas/WhatsAppCallingSettings"}},"required":["connection_id","phone_number_id","settings"]},"WhatsAppSipCredential":{"type":"object","properties":{"hostname":{"type":"string"},"port":{"type":"integer"},"sip_user_password":{"type":"string","description":"Meta-generated digest password for this SIP peer."}},"required":["sip_user_password"]},"InstagramMedia":{"type":"object","properties":{"id":{"type":"string"},"caption":{"type":["string","null"]},"media_url":{"type":["string","null"],"description":"Short-lived CDN URL, or null when Meta omits it. A CAROUSEL_ALBUM parent may carry its own cover URL or not — read `children` for the per-item URLs rather than relying on the parent."},"permalink":{"type":["string","null"]},"timestamp":{"type":["string","null"],"description":"ISO-8601 publish time."},"media_type":{"type":["string","null"],"description":"IMAGE | VIDEO | CAROUSEL_ALBUM (Meta-owned enum)."},"thumbnail_url":{"type":["string","null"],"description":"VIDEO/REELS only."},"children":{"type":["array","null"],"items":{"$ref":"#/components/schemas/InstagramMediaChild"},"description":"Ordered carousel items; null for non-carousel media."}},"required":["id","caption","media_url","permalink","timestamp","media_type","thumbnail_url","children"]},"InstagramMediaChild":{"type":"object","properties":{"id":{"type":"string"},"media_type":{"type":["string","null"],"description":"IMAGE | VIDEO (Meta-owned enum)."},"media_url":{"type":["string","null"]},"thumbnail_url":{"type":["string","null"],"description":"Video items only."},"permalink":{"type":["string","null"]}},"required":["id","media_type","media_url","thumbnail_url","permalink"]},"InstagramConversation":{"type":"object","properties":{"id":{"type":"string","description":"Conversation id — pass to the messages endpoint."},"updated_time":{"type":["string","null"],"description":"When the last message was added. This is a LAST-ACTIVITY stamp: Meta exposes no conversation-creation time, so it cannot tell you how far back a thread goes."},"participants":{"type":"array","items":{"$ref":"#/components/schemas/InstagramConversationParticipant"}}},"required":["id","updated_time","participants"]},"InstagramConversationParticipant":{"type":"object","properties":{"id":{"type":"string","description":"IGSID of the participant (or the account's own id)."},"username":{"type":["string","null"],"description":"@handle — Instagram threads."},"name":{"type":["string","null"],"description":"Display name — Page threads only."}},"required":["id","username","name"]},"InstagramConversationMessage":{"type":"object","properties":{"id":{"type":"string"},"created_time":{"type":["string","null"],"description":"ISO-8601 send time."},"message":{"type":["string","null"],"description":"Text body, or null when Meta withheld it — the documented outcome for any message past the 20 most recent in the thread. The id and created_time still identify the message."},"from":{"$ref":"#/components/schemas/InstagramMessageParty"},"to":{"type":["array","null"],"items":{"$ref":"#/components/schemas/InstagramMessageParty"}},"attachments":{"type":["array","null"],"items":{"$ref":"#/components/schemas/InstagramMessageAttachment"}},"shares":{"type":["array","null"],"items":{"$ref":"#/components/schemas/InstagramMessageAttachment"}},"reactions":{"type":["array","null"],"items":{"type":"object","properties":{"reaction":{"type":["string","null"]},"username":{"type":["string","null"]}},"required":["reaction","username"]}},"is_unsupported":{"type":["boolean","null"],"description":"Meta returns this only when true."}},"required":["id","created_time","message","from","to","attachments","shares","reactions","is_unsupported"]},"InstagramMessageParty":{"type":["object","null"],"properties":{"id":{"type":"string"},"username":{"type":["string","null"]},"name":{"type":["string","null"]},"email":{"type":["string","null"],"description":"Page threads only."}},"required":["id","username","name","email"]},"InstagramMessageAttachment":{"type":"object","properties":{"id":{"type":["string","null"]},"mime_type":{"type":["string","null"]},"name":{"type":["string","null"]},"file_url":{"type":["string","null"]},"image_url":{"type":["string","null"]},"video_url":{"type":["string","null"]}},"required":["id","mime_type","name","file_url","image_url","video_url"],"description":"Meta returns only a URL for shared media — a shared post or reel does not come back as a re-hostable asset."},"ShopifyAppShop":{"type":"object","properties":{"shop_domain":{"type":"string","example":"acme-store.myshopify.com"},"prebound_workspace_id":{"type":["string","null"],"format":"uuid"},"prebound_profile_id":{"type":["string","null"],"format":"uuid"},"status":{"type":"string","example":"pending"}},"required":["shop_domain","prebound_workspace_id","prebound_profile_id","status"]},"ShopifyApp":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"client_id":{"type":"string"},"kind":{"type":"string","enum":["public","custom_distribution"]},"merchant_label":{"type":"string","description":"Which Merchant this app was made available to — the Shopify API Terms §2.3.20 audit trail."},"plus_org_id":{"type":["string","null"],"description":"Non-null asserts every bound store belongs to ONE Shopify Plus organization, which is the only case §2.3.20 permits a Custom Application to serve more than one store."},"status":{"type":"string","enum":["active","revoked"]},"scopes":{"type":"array","items":{"type":"string"},"description":"The exact OAuth scopes this app's authorize URL requests. `read_all_orders` is approval-gated PER APP by Shopify, so a freshly registered custom-distribution app defaults to `SHOPIFY_OAUTH_SCOPES` minus the gated scopes — asking for one the app has not been approved for makes Shopify 400 the authorize URL before the merchant sees a consent screen. Widen it with PATCH once Shopify approves the access request.","example":["read_orders","read_customers","write_pixels","read_products","read_checkouts","read_customer_events","read_inventory"]},"created_by":{"type":["string","null"],"format":"uuid"},"created_at":{"type":"string"}},"required":["id","client_id","kind","merchant_label","plus_org_id","status","scopes","created_by","created_at"]},"OrganicTouch":{"type":"object","properties":{"id":{"type":"string","example":"0f1e2d3c-4b5a-6789-abcd-ef0123456789"},"touch_time":{"type":"string","example":"2026-03-14T18:05:00Z"},"subtype":{"type":"string","description":"Organic touch subtype (e.g. `ig_comment`, `ig_dm`, `whatsapp_click`).","example":"ig_comment"},"channel":{"type":"string","example":"Organic Social"},"customer_profile_id":{"type":["string","null"]},"ig_media_id":{"type":["string","null"]},"credited_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Revenue credited to this touch under the profile's default model."},"credit_weight":{"type":"number","description":"Fraction of the conversion's credit this touch received (0–1)."},"metadata":{"type":["object","null"],"additionalProperties":{}}},"required":["id","touch_time","subtype","channel","customer_profile_id","ig_media_id","credited_revenue","credit_weight","metadata"]},"DmAd":{"type":"object","properties":{"ad_external_id":{"type":"string"},"ad_name":{"type":"string"},"campaign_name":{"type":["string","null"]},"ad_set_name":{"type":["string","null"]},"status":{"type":"string"},"channel":{"type":"string"},"recommended":{"type":"boolean","description":"There is an open scale/optimize/kill recommendation for this ad."},"data_quality_tier":{"type":["string","null"],"description":"Which signal drives the numbers: cash / outcomes / engagement / meta_only / none."},"composite_score":{"type":["number","null"]},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"conversations_started":{"type":"number"},"high_intent_conversations":{"type":"number"},"high_intent_rate":{"type":"number"},"high_intent_band":{"type":"string","enum":["low","medium","high"],"description":"Wilson-confidence band on `high_intent_rate` — a small-sample ad reads `low` even with a flattering rate."},"cost_per_conversation":{"type":["number","null"]},"cost_per_high_intent":{"type":["number","null"],"description":"The hero metric: spend ÷ qualified conversations."},"first_reply_rate":{"type":"number"},"depth_3_rate":{"type":"number"},"attributed_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"roas":{"type":"number"},"booked_appointments":{"type":"number"},"closed_deals":{"type":"number"},"intent_breakdown":{"type":"array","items":{"$ref":"#/components/schemas/DmAdIntentSlice"}},"recommendations":{"type":"array","items":{"$ref":"#/components/schemas/DmAdRecommendation"}}},"required":["ad_external_id","ad_name","campaign_name","ad_set_name","status","channel","recommended","data_quality_tier","composite_score","spend","conversations_started","high_intent_conversations","high_intent_rate","high_intent_band","cost_per_conversation","cost_per_high_intent","first_reply_rate","depth_3_rate","attributed_revenue","roas","booked_appointments","closed_deals","intent_breakdown","recommendations"]},"DmAdIntentSlice":{"type":"object","properties":{"intent":{"type":"string","example":"pricing"},"count":{"type":"number"},"share":{"type":"number","description":"Share of scored conversations (0–1)."}},"required":["intent","count","share"]},"DmAdRecommendation":{"type":"object","properties":{"id":{"type":"string"},"kind":{"type":"string","example":"scale_winner"},"expected_impact_dollars":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"confidence":{"type":"number"},"snapshot_date":{"type":"string","example":"2026-03-25"},"rationale_text":{"type":["string","null"]}},"required":["id","kind","expected_impact_dollars","confidence","snapshot_date","rationale_text"]},"Recommendation":{"type":"object","properties":{"id":{"type":"string"},"workspace_id":{"type":"string"},"profile_id":{"type":"string"},"kind":{"$ref":"#/components/schemas/RecommendationKind"},"risk_tier":{"type":"string","enum":["safe","medium","manual_only"],"description":"`manual_only` recommendations are never auto-appliable."},"status":{"$ref":"#/components/schemas/RecommendationStatus"},"rationale":{"type":"object","properties":{"source":{"type":"string","example":"rules"},"text":{"type":["string","null"]},"evidence":{"type":"object","additionalProperties":{}}},"required":["source","text","evidence"]},"suggested_modifications":{"type":"object","additionalProperties":{}},"target":{"type":"object","properties":{"ad_id":{"type":["string","null"]},"adset_id":{"type":["string","null"]},"external_action_url":{"type":["string","null"]}},"required":["ad_id","adset_id","external_action_url"]},"expected_impact_dollars":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"confidence":{"type":"number"},"rec_rank_score":{"type":"number"},"score_window":{"type":"string","example":"28d"},"snapshot_date":{"type":"string"},"expires_at":{"type":"string"},"applied_at":{"type":["string","null"]},"verified_at":{"type":["string","null"]},"dismissed_at":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","workspace_id","profile_id","kind","risk_tier","status","rationale","suggested_modifications","target","expected_impact_dollars","confidence","rec_rank_score","score_window","snapshot_date","expires_at","applied_at","verified_at","dismissed_at","created_at"]},"RecommendationKind":{"type":"string","enum":["scale_winner","pause_underperformer","budget_reallocate_winners","creative_refresh_pre_fatigue"]},"RecommendationStatus":{"type":"string","enum":["open","applied","dismissed","superseded","expired","rolled_back"]},"RecommendationApplyLeg":{"type":"object","properties":{"kind":{"type":"string","description":"Which write this leg performed (e.g. `budget_update`, `ad_pause`)."},"status":{"type":"string"},"replayed":{"type":"boolean","description":"True when the executor's deterministic key found this leg already written at Meta."},"detail":{"type":"object","additionalProperties":{}}},"required":["kind","status","replayed"]},"WaJoinResult":{"type":"object","properties":{"join_method":{"$ref":"#/components/schemas/WaJoinMethod"},"session_join_method":{"type":"string","description":"The Atribu tier that produced the linkage, even when `join_method` is `ctwa_referral`."},"anonymous_id":{"type":["string","null"]},"session_id":{"type":["string","null"]},"customer_profile_id":{"type":["string","null"]},"wa_click_id":{"type":["string","null"]},"ctwa_clid":{"type":["string","null"]},"ambiguous":{"type":"boolean","description":"True when more than one distinct visitor matched — a deliberate non-join."},"ambiguous_tier":{"type":["string","null"]},"conversation_started":{"type":"boolean","description":"Whether this call wrote (or changed) the outcome event."},"outcome_event_id":{"type":["string","null"]}},"required":["join_method","session_join_method","anonymous_id","session_id","customer_profile_id","wa_click_id","ctwa_clid","ambiguous","ambiguous_tier","conversation_started","outcome_event_id"]},"WaJoinMethod":{"type":"string","enum":["ctwa_referral","text_token","text_correlation","phone_identity_submitted","phone_identity_early","none"]},"WaJoinRequest":{"type":"object","properties":{"merchant_phone":{"type":"string","minLength":5,"maxLength":32,"description":"The number the message was sent TO. Digits or E.164.","example":"+56912345678"},"sender_phone":{"type":["string","null"],"minLength":5,"maxLength":32,"description":"The number it came FROM. Optional — a join can succeed on text alone."},"message_text":{"type":["string","null"],"maxLength":8192,"description":"The message body, verbatim. Normalisation happens server-side."},"occurred_at":{"type":"string","format":"date-time","description":"The MESSAGE's own timestamp, ISO-8601 — never receipt time. A backlog drain stamped with flush time would miss every correlation window.","example":"2026-03-25T14:03:11-03:00"},"ctwa_clid":{"type":["string","null"],"maxLength":512,"description":"Meta's click-to-WhatsApp click id, when the conversation came from a CTWA ad."},"token":{"type":["string","null"],"maxLength":32,"description":"An explicit `[#ABC123]`, when the caller stripped it out of the body itself."}},"required":["merchant_phone","occurred_at"]},"AttributionModelSlice":{"type":"object","properties":{"model":{"type":"string","example":"last_touch"},"totals":{"$ref":"#/components/schemas/AttributionModelTotals"},"campaigns":{"type":"array","items":{"$ref":"#/components/schemas/AttributionModelCampaign"}}},"required":["model","totals","campaigns"]},"AttributionModelTotals":{"type":"object","properties":{"conversion_count":{"type":"number"},"lead_count":{"type":"number"},"appointment_count":{"type":"number"},"payment_count":{"type":"integer"},"other_count":{"type":"number"},"credited_conversions":{"type":"number"},"direct_credited_conversions":{"type":"number"},"inherited_credited_conversions":{"type":"number"},"attributed_credited_conversions":{"type":"number","description":"Directly-tracked credited conversions on ATTRIBUTED campaigns only (the unattributed bucket excluded). Every other field here is a plain sum over `campaigns`; this one is the number Atribu's own Attribution view puts in its KPI strip."},"total_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"cash_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"direct_cash_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"inherited_cash_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["conversion_count","lead_count","appointment_count","payment_count","other_count","credited_conversions","direct_credited_conversions","inherited_credited_conversions","attributed_credited_conversions","total_value","cash_value","direct_cash_value","inherited_cash_value","spend"]},"AttributionModelCampaign":{"type":"object","properties":{"campaign_external_id":{"type":"string","description":"Platform-side campaign id, or `__unattributed__` for the bucket holding conversions with no campaign-bearing touch.","example":"120210000000000001"},"campaign_name":{"type":"string"},"platform":{"type":"string","example":"meta_ads"},"is_unattributed":{"type":"boolean"},"conversion_count":{"type":"number"},"lead_count":{"type":"number"},"appointment_count":{"type":"number"},"payment_count":{"type":"integer"},"other_count":{"type":"number"},"credited_conversions":{"type":"number","description":"Sum of credit_weight. A conversion's weights sum to 1 across its whole path under every model, so this is fractional under linear / time_decay and whole under last_touch.","example":2.5},"total_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Credit-weighted value across all revenue types (cash + pipeline)."},"cash_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"The cash subset of total_value (direct + inherited)."},"direct_cash_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Directly-tracked cash. The ONLY cash that belongs in a ROAS numerator — inherited (bridged lead→cash) credit is reported separately and never summed in."},"inherited_cash_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Cash credited via a bridged lead→cash touch. Never in ROAS."},"direct_credited_conversions":{"type":"number"},"inherited_credited_conversions":{"type":"number"},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Ad spend for this campaign in the window. 0 on the unattributed bucket."}},"required":["campaign_external_id","campaign_name","platform","is_unattributed","conversion_count","lead_count","appointment_count","payment_count","other_count","credited_conversions","total_value","cash_value","direct_cash_value","inherited_cash_value","direct_credited_conversions","inherited_credited_conversions","spend"]},"AttributionExplorer":{"type":"object","properties":{"event":{"$ref":"#/components/schemas/AttributionExplorerEvent"},"model":{"type":"string"},"resolution_strategy":{"type":"string","enum":["event_utm_direct","event_utm_fuzzy","timeline_direct","timeline_fuzzy","unattributed"]},"credits":{"type":"array","items":{"$ref":"#/components/schemas/AttributionCredit"}},"totals":{"$ref":"#/components/schemas/AttributionCreditTotals"},"timeline":{"type":"array","items":{"$ref":"#/components/schemas/AttributionTimelineEvent"}},"models":{"type":"array","items":{"type":"object","properties":{"model":{"type":"string"},"credits":{"type":"array","items":{"$ref":"#/components/schemas/AttributionCredit"}},"totals":{"$ref":"#/components/schemas/AttributionCreditTotals"}},"required":["model","credits","totals"]}}},"required":["event","model","resolution_strategy","credits","totals","timeline"]},"AttributionExplorerEvent":{"type":"object","properties":{"id":{"type":"string"},"event_type":{"type":"string"},"event_time":{"type":"string"},"value_amount":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"currency":{"type":["string","null"]},"customer_profile_id":{"type":["string","null"]},"customer_key":{"type":["string","null"]},"first_touch_utm":{"$ref":"#/components/schemas/AttributionUtm"},"latest_touch_utm":{"$ref":"#/components/schemas/AttributionUtm"}},"required":["id","event_type","event_time","value_amount","currency","customer_profile_id","customer_key","first_touch_utm","latest_touch_utm"]},"AttributionUtm":{"type":["object","null"],"properties":{"campaign_id":{"type":["string","null"]},"ad_set_id":{"type":["string","null"]},"ad_id":{"type":["string","null"]},"utm_campaign":{"type":["string","null"]},"utm_source":{"type":["string","null"]},"utm_medium":{"type":["string","null"]},"utm_content":{"type":["string","null"]},"fbclid":{"type":["string","null"]},"gclid":{"type":["string","null"]}},"required":["campaign_id","ad_set_id","ad_id","utm_campaign","utm_source","utm_medium","utm_content","fbclid","gclid"]},"AttributionCredit":{"type":"object","properties":{"model":{"type":"string"},"credit_weight":{"type":"number","description":"This touchpoint's share of the conversion, 0–1."},"credit_value":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"This touchpoint's share OF THE CONVERSION VALUE — already weighted. Sum it directly; do not multiply by credit_weight."},"campaign_external_id":{"type":["string","null"]},"campaign_name":{"type":["string","null"]},"ad_set_external_id":{"type":["string","null"]},"ad_set_name":{"type":["string","null"]},"ad_external_id":{"type":["string","null"]},"ad_name":{"type":["string","null"]}},"required":["model","credit_weight","credit_value","campaign_external_id","campaign_name","ad_set_external_id","ad_set_name","ad_external_id","ad_name"]},"AttributionCreditTotals":{"type":"object","properties":{"credit_weight":{"type":"number","description":"Sums to 1 for a credited conversion, 0 for one with no ad touch."},"credit_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["credit_weight","credit_value"]},"AttributionTimelineEvent":{"type":"object","properties":{"id":{"type":"string"},"event_type":{"type":"string","example":"lead_created"},"event_time":{"type":"string"},"first_touch_utm":{"$ref":"#/components/schemas/AttributionUtm"},"latest_touch_utm":{"$ref":"#/components/schemas/AttributionUtm"},"has_matched_touchpoint":{"type":"boolean"},"matched_by":{"type":"string","enum":["platform_id","fuzzy_campaign_name","none"]}},"required":["id","event_type","event_time","first_touch_utm","latest_touch_utm","has_matched_touchpoint","matched_by"]},"ReportSendRequest":{"type":"object","properties":{"recipient_emails":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":10,"example":["ops@dealer.cl"]}},"required":["recipient_emails"]},"ReportSchedulesBulkRequest":{"type":"object","properties":{"profile_ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100},"apply":{"$ref":"#/components/schemas/ReportSchedulesBulkApply"}},"required":["profile_ids","apply"]},"ReportBulkGenerateRequest":{"type":"object","properties":{"profile_ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":20},"date_from":{"type":"string","example":"2026-08-01"},"date_to":{"type":"string","example":"2026-08-31"},"send_email":{"type":"boolean","description":"Defaults to false — a dropped field must never mail real clients."}},"required":["profile_ids","date_from","date_to"]},"MePatch":{"type":"object","properties":{"full_name":{"type":"string","minLength":1,"maxLength":200,"description":"The display name. Empty or whitespace-only is a 400, never a silent clear — clearing a name is not something a save button should be able to do by accident."},"locale":{"type":"string","enum":["en","es"],"description":"The console's UI language. Anything else is a 400."}}},"UpdateMePasswordBody":{"type":"object","properties":{"current_password":{"type":"string","description":"Required whenever the caller has an email/password identity. Omit only for an OAuth-only user setting a password for the first time."},"new_password":{"type":"string","minLength":8}},"required":["new_password"]},"RequestMeEmailChangeBody":{"type":"object","properties":{"new_email":{"type":"string","format":"email"}},"required":["new_email"]},"CreateMcpTokenBody":{"type":"object","properties":{"name":{"type":"string"},"scopes":{"type":"array","items":{"type":"string","enum":["mcp:read","mcp:read_pii","mcp:write"]},"description":"Defaults to `[\"mcp:read\"]` when omitted."},"expires_at":{"type":"string","description":"ISO timestamp. Omit for a token that never expires."}}},"RotateMcpTokenBody":{"type":"object","properties":{"name":{"type":"string"}}},"CreateApiKeyBody":{"type":"object","properties":{"profile_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"scopes":{"type":"array","items":{"type":"string"},"description":"Defaults to `[\"analytics:read\", \"campaigns:read\", \"conversions:read\", \"realtime:read\"]` when omitted. Must be a subset of the published scope catalogue."},"expires_at":{"type":"string","description":"ISO timestamp. Omit for a key that never expires."}},"required":["profile_id"]},"ConnectionSyncResult":{"type":"object","properties":{"queued":{"type":"boolean","enum":[true]},"jobs_enqueued":{"type":"integer","description":"Sync jobs sent to the worker queue. `0` with a named `provider` never reaches you — it is the 409 below."},"catalog_jobs_enqueued":{"type":"integer","description":"Shopify only, and deliberately NOT folded into `jobs_enqueued`: the catalog/inventory mirror rides its own queue. `0` for every other provider."}},"required":["queued","jobs_enqueued","catalog_jobs_enqueued"]},"ConnectionSyncRequest":{"type":"object","properties":{"provider":{"type":["string","null"],"enum":["whatsapp","instagram","email","google_calendar","google_calendar_read","microsoft_calendar_read","meta_ads","google_ads","google_search_console","gohighlevel","shopify","stripe","mercadopago","notion","manychat","fintoc",null],"description":"Sync only this provider's connections. OMIT IT to sync every connected provider on the profile — which is what the onboarding path does, and the only shape that answers 200 with `jobs_enqueued: 0` instead of 409 when nothing is enqueued.","example":"meta_ads"},"resource":{"type":["string","null"],"enum":["contacts","opportunities","pipelines",null],"description":"GoHighLevel ONLY — the underlying function ignores it for every other connector, so naming it with any other `provider` is a 400 rather than a silently wider sync. Omit it to sync contacts and opportunities (the default set); `pipelines` is opt-in.","example":"contacts"},"force_full":{"type":["boolean","null"],"description":"Bypass the incremental cursor and re-read the connector's full window — the \"hard refresh\" path. Defaults to false."}}},"ConnectionSyncStatus":{"type":"object","properties":{"connection_status":{"type":"string","description":"The connection's own state, or `disconnected` when this profile holds no connection for the provider — which is a 200, not a 404.","example":"syncing"},"is_syncing":{"type":"boolean"},"started_at":{"type":["string","null"]},"heartbeat_at":{"type":["string","null"],"description":"Last progress tick. A stale heartbeat with `is_syncing` is a stalled run."},"last_synced_at":{"type":["string","null"]},"connection_error":{"type":["string","null"],"description":"The connection-level failure (an expired token, say) — distinct from the per-resource errors below."},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ConnectionSyncResource"},"description":"Per-resource progress. Populated for GoHighLevel, the only connector that reports it; `[]` elsewhere, and only for resources this run actually touched."}},"required":["connection_status","is_syncing","started_at","heartbeat_at","last_synced_at","connection_error","resources"]},"ConnectionSyncResource":{"type":"object","properties":{"resource":{"type":"string","enum":["pipelines","opportunities","contacts"]},"status":{"type":"string","enum":["queued","running","done","failed"]},"completed":{"type":"integer"},"total":{"type":["integer","null"],"description":"Null until the connector has counted the work. See `scope_known`."},"scope_known":{"type":"boolean","description":"False while `total` is still being discovered — render a spinner, not 0%."},"last_error":{"type":["string","null"]},"updated_at":{"type":["string","null"]}},"required":["resource","status","completed","total","scope_known","last_error","updated_at"]},"DisconnectedConnection":{"type":"object","properties":{"id":{"type":"string","example":"uuid"},"provider":{"type":["string","null"]},"external_account_id":{"type":["string","null"]},"already_disconnected":{"type":"boolean","description":"True when the row was already `disconnected` and this call only cleaned the tombstone up. No `connection.revoked` webhook is emitted in that case — the consumer was told when it went dead."},"ghl_uninstall":{"type":["string","null"],"enum":["uninstalled","skipped_sibling_connections","skipped_no_app_id","skipped_not_ghl","failed",null],"description":"GoHighLevel only (`null` elsewhere): whether the Marketplace uninstall the App Review guidelines require actually ran. It is best-effort — a `failed` here is a fact for your logs, never a failed disconnect. `skipped_sibling_connections` means another Atribu profile still holds that GHL location, so the install must stay."}},"required":["id","provider","external_account_id","already_disconnected","ghl_uninstall"]},"ProfileRecomputeResult":{"type":"object","properties":{"queued":{"type":"boolean","enum":[true]},"jobs_enqueued":{"type":"integer","description":"`1` when the job landed, `0` when it coalesced onto one already queued — which is success, not a refusal: the pending run will cover your change."},"model":{"type":["string","null"]}},"required":["queued","jobs_enqueued","model"]},"ProfileRecomputeRequest":{"type":"object","properties":{"model":{"type":["string","null"],"description":"Recompute only this attribution model. OMIT IT for every model, which is what the console asks for and what you almost certainly want after a merge or a rule change. `GET /api/v1/attribution/models` lists the accepted values.","example":"last_touch"}}},"ProfileRecomputeStatus":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"},"pending":{"type":"boolean","description":"True while a recompute for this profile is queued or running. Numbers read during a pending recompute are the PREVIOUS run's — correct, but not yet reflecting whatever you just changed."}},"required":["profile_id","pending"]}},"parameters":{"date_from":{"schema":{"$ref":"#/components/schemas/date_from"},"required":true,"description":"Start date (YYYY-MM-DD, inclusive)","name":"date_from","in":"query"},"date_to":{"schema":{"$ref":"#/components/schemas/date_to"},"required":true,"description":"End date (YYYY-MM-DD, inclusive). Max range: 366 days.","name":"date_to","in":"query"},"limit":{"schema":{"$ref":"#/components/schemas/limit"},"required":false,"description":"Max results to return (default 10, max 100)","name":"limit","in":"query"},"model":{"schema":{"$ref":"#/components/schemas/model"},"required":false,"description":"Attribution model. One of: last_touch, first_touch, linear, time_decay, last_non_direct","name":"model","in":"query"}}},"paths":{"/api/v1/funnels":{"get":{"summary":"Get a conversion funnel's stage counts","description":"Returns per-stage counts for one of the profile's configured funnels (`funnel_definitions` / `funnel_steps`). Omit `funnel_id` to read the profile's default funnel; 404 if the profile has none, or if `funnel_id` names a funnel that does not belong to it.\n\nA funnel that includes a `page_view` step reads the Ephemeral Buffer (`public.views`, 30-day retention, ADR 0014 d2) — unlike the internal dashboard, which silently clamps that case, this endpoint REJECTS (400 `invalid_date_range`) a `date_from` older than the retention window rather than truncating it without saying so. A funnel made only of `outcome_event` steps reads durable `outcome_events` and is unbounded (subject to the general 366-day cap).","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string","description":"Defaults to the profile's default funnel."},"required":false,"description":"Defaults to the profile's default funnel.","name":"funnel_id","in":"query"},{"schema":{"type":"string","enum":["all","pipeline_only","contact_only"],"description":"Defaults to 'all'."},"required":false,"description":"Defaults to 'all'.","name":"scope","in":"query"}],"responses":{"200":{"description":"Funnel stage data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Funnel"},"meta":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-03-01"},"date_to":{"type":"string","example":"2026-03-25"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid parameter, or the range exceeds the Ephemeral Buffer window for a page_view funnel","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No matching funnel for this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/cohorts/new-vs-returning":{"get":{"summary":"Get new vs. returning customer counts","description":"Counts of `order_placed` outcome events in the window, split by whether the customer was new or returning at that event. Reads durable `outcome_events` — unbounded, subject to the general 366-day range cap.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"}],"responses":{"200":{"description":"New vs. returning counts","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/NewVsReturning"},"meta":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-03-01"},"date_to":{"type":"string","example":"2026-03-25"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/attribution/messaging":{"get":{"summary":"Get messaging/DM-attributed conversions","description":"Returns cash conversions (`revenue_type = 'cash'`, per data-model.md) in the window that carry at least one messaging/DM-modeled attribution credit (`attribution_modeled_credits`, the CTM/entry_source pipeline — see AGENTS.md and .claude/rules/data-model.md), one row per conversion with its top-probability candidate ad. Modeled credit here is decision-support, never folded into ROAS. `customer_name`/`customer_email` require the `customers:read` scope in addition to `analytics:read`; without it those two fields come back null.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"2026-01-01"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-08-25"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string","enum":["exact_dm_ad","exact_follow_proxy","native_conversation_model","native_aggregate_only","organic_or_unknown"],"description":"Filter to one attribution_mode lane. Omit for all lanes."},"required":false,"description":"Filter to one attribution_mode lane. Omit for all lanes.","name":"mode","in":"query"},{"schema":{"type":"string","example":"50","description":"Default 50, max 200."},"required":false,"description":"Default 50, max 200.","name":"limit","in":"query"}],"responses":{"200":{"description":"Messaging-attributed conversions","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MessagingAttributionOutcome"}},"meta":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-03-01"},"date_to":{"type":"string","example":"2026-03-25"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/attribution/messaging/conversions/{id}":{"get":{"summary":"Explain one conversion's messaging attribution","description":"Every candidate ad the messaging/DM attribution model considered for ONE conversion, with the probability and confidence band it assigned, ordered highest-probability first. This is the drill behind a row of `GET /api/v1/attribution/messaging`.\n\n`probability` is MODELLED credit, not cash, and is never folded into ROAS.\n\nRequires `customers:read`: the whole response describes one person's conversion, and `evidence` can carry the text of their messages. A conversion belonging to another profile answers **404**, not 403 and not an empty chain — a foreign id and a nonexistent one are the same fact to a caller, and separating them would let one enumerate conversion ids.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"`conversions.id` (uuid).","example":"uuid"},"required":true,"description":"`conversions.id` (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"The conversion's candidate chain","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MessagingConversionChain"},"meta":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-03-01"},"date_to":{"type":"string","example":"2026-03-25"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the customers:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such conversion for this profile (also the answer for a malformed id)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"500":{"description":"The read failed — quote `request_id`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/ads/{id}/creative-analysis":{"get":{"summary":"Get an ad's AI creative analysis","description":"The stored AI analysis of one ad's creative — its angle, offer, hook and claim style, plus (for video ads) a verbatim transcript and a beat-by-beat scene timeline. `{id}` is the ad's EXTERNAL (platform) id, the same value `GET /api/v1/top-performers` returns as `ad_external_id`.\n\n**This is a read, never a run.** The analysis is produced by a scheduled daily background pass and this endpoint serves what that pass already stored; it issues no model call, counts against no allowance, and cannot be used to trigger one. Two consequences follow. Coverage is not guaranteed: an ad the pass has not reached — including every ad on a profile where the pass is switched off — answers 404 with `code: \"not_found\"`, distinct in message from an ad id that does not belong to this key's profile. And freshness is the pass's, not the request's: read `analyzed_at`.\n\n`method` tells you which tier produced the row and therefore how much of `video` is populated. Embeddings and the derived per-role duration scalars are deliberately not published — they are ML ranker inputs, and `video.scenes` carries the same observations in the form they were made.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The ad's platform/external id."},"required":true,"description":"The ad's platform/external id.","name":"id","in":"path"}],"responses":{"200":{"description":"The ad's stored creative analysis","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdCreativeAnalysisResponse"}}}},"404":{"description":"No ad with that external id belongs to this key's profile, or the ad has no stored creative analysis at the current analysis version.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Queue an on-demand creative analysis","description":"Ask for one ad's creative to be analysed now, instead of waiting for the daily background pass. **This never runs a model on the request; it queues one.** The response is a receipt, and the analysis itself arrives through `GET` on this same path — which is what `Location` points at.\n\n**Two ceilings gate ADMISSION**, both per UTC day and both configurable per tenant: 20 analyses per profile and 100 per API key by default. Exceeding either answers `429` with `Retry-After` set to the seconds remaining until the next UTC midnight; the message names which ceiling fired, the counters, and the reset instant. This is separate from — and additional to — the ordinary per-minute rate limit, and the endpoint also carries its own hourly burst cap.\n\n**A third limit gates the RUN, and it is a different resource: the workspace's daily creative-analysis allowance**, shared with the background pass and set by the plan. An admitted job whose workspace has already spent today's allowance is NOT refused and NOT failed — it stays `queued` and waits for the allowance to refill at the next 00:00 UTC, then runs. It waits at most one day: if the allowance is still spent after that reset the job ends `blocked`, which costs nothing against the two admission ceilings above and can be re-requested. So a `202` promises the analysis will be attempted, not that it will be attempted today.\n\n**Idempotent for 24 hours.** The key is the ad plus its creative: the ad's name, headline, body, video and thumbnail. A second POST inside that window, with none of those changed, returns the SAME job with `200` and `reused: true` and consumes no budget. A new job is admitted with `202`. Edit the creative and the next POST is a new job, which is the behaviour you want — the old analysis describes an ad that no longer exists.\n\n**No request body.** The ad is in the path and the caller is in the credential; there is nothing else to say, and in particular there is no way to ask for a re-run of an unchanged creative or to exceed the budget.\n\nThe scope is `creatives:write`, which no OAuth connect scope grants by default — a workspace admin opts a key into it. A read credential cannot cause AI spend.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The ad's platform/external id."},"required":true,"description":"The ad's platform/external id.","name":"id","in":"path"}],"responses":{"200":{"description":"An existing job for this ad and this creative was returned instead (`reused: true`). Nothing new was queued and no budget was consumed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdCreativeAnalysisJobResponse"}}}},"202":{"description":"A new job was admitted and is queued. `Location` is the GET on this path — poll it for the analysis.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdCreativeAnalysisJobResponse"}}}},"404":{"description":"No ad with that external id belongs to this key's profile.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"A daily budget is exhausted (per profile or per API key), or the endpoint's own hourly burst cap was hit. `Retry-After` carries the wait in seconds.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["creatives:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/ads/level-counts":{"get":{"summary":"Count the ad entities that had delivery in a window","description":"How many campaigns, ad sets and ads the profile had delivery for in the window — the numbers a level switcher shows beside each option, and the cheapest way to ask whether a level is worth requesting at all before calling `GET /api/v1/campaigns?level=`.\n\nEvery level is always present. A level with no delivery answers `0`, so a missing key never has to be told apart from a level with nothing in it.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Start of the window, `YYYY-MM-DD`.","example":"2026-08-01"},"required":true,"description":"Start of the window, `YYYY-MM-DD`.","name":"date_from","in":"query"},{"schema":{"type":"string","description":"End of the window, inclusive, `YYYY-MM-DD`.","example":"2026-08-26"},"required":true,"description":"End of the window, inclusive, `YYYY-MM-DD`.","name":"date_to","in":"query"}],"responses":{"200":{"description":"Entity counts per level","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AdsLevelCounts"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"},"date_from":{"type":"string","example":"2026-08-01"},"date_to":{"type":"string","example":"2026-08-26"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing or malformed date range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/campaigns/unattributed":{"get":{"summary":"The reconciling remainder under an ads ranking","description":"Attributed outcomes at `level` that carry no entity id AT THAT LEVEL — almost always conversions credited to a touch that names an ad but no parent campaign or ad set.\n\nIt exists so a consumer can close the books. `Σ(GET /api/v1/campaigns?level=X)` is strictly less than the profile's attributed total; add this bucket and the identity holds at every level. Without it there is no way to tell a reconciliation gap from a bug.\n\nSame `model` / `date_from` / `date_to` / `level` the ranking takes, and it must be called with the SAME arguments — the bucket is the complement of one particular list, not a standalone figure.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Start of the window, `YYYY-MM-DD`.","example":"2026-08-01"},"required":true,"description":"Start of the window, `YYYY-MM-DD`.","name":"date_from","in":"query"},{"schema":{"type":"string","description":"End of the window, inclusive, `YYYY-MM-DD`.","example":"2026-08-26"},"required":true,"description":"End of the window, inclusive, `YYYY-MM-DD`.","name":"date_to","in":"query"},{"schema":{"type":"string","enum":["last_touch","first_touch","linear","time_decay","last_non_direct"],"description":"Attribution model. Defaults to `last_touch`."},"required":false,"description":"Attribution model. Defaults to `last_touch`.","name":"model","in":"query"},{"schema":{"type":"string","enum":["campaign","ad_set","ad"],"description":"Which ranking this is the remainder of. Defaults to `campaign`."},"required":false,"description":"Which ranking this is the remainder of. Defaults to `campaign`.","name":"level","in":"query"}],"responses":{"200":{"description":"The unattributed bucket at that level","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AdsUnattributed"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"},"date_from":{"type":"string","example":"2026-08-01"},"date_to":{"type":"string","example":"2026-08-26"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing or malformed date range, or an unknown model/level","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/ads/trend":{"get":{"summary":"Daily delivery series for many entities at once","description":"The bulk form: one request covers every entity at `level`, which is what a ranking table with a sparkline per row needs. Restrict it with `ids` when only some are on screen.\n\nDelivery only — spend and clicks — and that is deliberate. Attributed revenue is NOT published per day here: a model's credit is assigned over a window, so summing daily credit into a period total is not the same number as asking for the period, and a series that invited it would be a trap. Ask `GET /api/v1/campaigns?level=` for attributed totals.\n\nRows are keyed by `platform_id` (the provider's own id), the same value `GET /api/v1/campaigns?level=` returns and a deep link carries. A day on which an entity had no delivery is absent rather than zero-filled.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Start of the window, `YYYY-MM-DD`.","example":"2026-08-01"},"required":true,"description":"Start of the window, `YYYY-MM-DD`.","name":"date_from","in":"query"},{"schema":{"type":"string","description":"End of the window, inclusive, `YYYY-MM-DD`.","example":"2026-08-26"},"required":true,"description":"End of the window, inclusive, `YYYY-MM-DD`.","name":"date_to","in":"query"},{"schema":{"type":"string","enum":["campaign","ad_set","ad"],"description":"Which level the ids belong to. Defaults to `campaign`."},"required":false,"description":"Which level the ids belong to. Defaults to `campaign`.","name":"level","in":"query"},{"schema":{"type":"string","description":"Comma-separated platform ids to restrict the series to. Omit for every entity at `level`. Capped at 1000 ids per request.","example":"120210000000000042,120210000000000043"},"required":false,"description":"Comma-separated platform ids to restrict the series to. Omit for every entity at `level`. Capped at 1000 ids per request.","name":"ids","in":"query"}],"responses":{"200":{"description":"One row per (entity, day) with delivery","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AdTrendPoint"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"},"date_from":{"type":"string","example":"2026-08-01"},"date_to":{"type":"string","example":"2026-08-26"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing or malformed date range, an unknown level, or too many ids","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/ads/{id}/trend":{"get":{"summary":"Daily delivery series for one entity","description":"The single-entity form of `GET /api/v1/ads/trend`. `{id}` is the entity's EXTERNAL (platform) id, the same convention `GET /api/v1/ads/{id}/creative-analysis` follows.\n\nDelivery only — spend and clicks — and that is deliberate. Attributed revenue is NOT published per day here: a model's credit is assigned over a window, so summing daily credit into a period total is not the same number as asking for the period, and a series that invited it would be a trap. Ask `GET /api/v1/campaigns?level=` for attributed totals.\n\nRows are keyed by `platform_id` (the provider's own id), the same value `GET /api/v1/campaigns?level=` returns and a deep link carries. A day on which an entity had no delivery is absent rather than zero-filled.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The entity's platform/external id."},"required":true,"description":"The entity's platform/external id.","name":"id","in":"path"},{"schema":{"type":"string","description":"Start of the window, `YYYY-MM-DD`.","example":"2026-08-01"},"required":true,"description":"Start of the window, `YYYY-MM-DD`.","name":"date_from","in":"query"},{"schema":{"type":"string","description":"End of the window, inclusive, `YYYY-MM-DD`.","example":"2026-08-26"},"required":true,"description":"End of the window, inclusive, `YYYY-MM-DD`.","name":"date_to","in":"query"},{"schema":{"type":"string","enum":["campaign","ad_set","ad"],"description":"Which level the id belongs to. Defaults to `ad`."},"required":false,"description":"Which level the id belongs to. Defaults to `ad`.","name":"level","in":"query"}],"responses":{"200":{"description":"One row per day with delivery. Empty when the entity had none.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AdTrendPoint"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"},"date_from":{"type":"string","example":"2026-08-01"},"date_to":{"type":"string","example":"2026-08-26"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing or malformed date range, or an unknown level","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/ads/{id}":{"get":{"summary":"One campaign, ad set or ad in full","description":"Everything the ads surface shows in its detail panel for a single entity. `{id}` is the entity's EXTERNAL (platform) id — the `platform_id` on every `GET /api/v1/campaigns?level=` row, and the same convention `GET /api/v1/ads/{id}/creative-analysis` uses. The internal `ad_entity_dim` uuid is never a public identifier.\n\n`level` is optional: the entity's level is resolved from the profile's own ad dimension. Send it when one external id legitimately exists at more than one level, which is the only case where the resolution is ambiguous.\n\nThe metrics are the SAME window-scoped figures `GET /api/v1/campaigns?level=` computes — one engine call, one attribution model — so a detail view can never disagree with the row the user clicked.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The entity's platform/external id."},"required":true,"description":"The entity's platform/external id.","name":"id","in":"path"},{"schema":{"type":"string","description":"Start of the window, `YYYY-MM-DD`.","example":"2026-08-01"},"required":true,"description":"Start of the window, `YYYY-MM-DD`.","name":"date_from","in":"query"},{"schema":{"type":"string","description":"End of the window, inclusive, `YYYY-MM-DD`.","example":"2026-08-26"},"required":true,"description":"End of the window, inclusive, `YYYY-MM-DD`.","name":"date_to","in":"query"},{"schema":{"type":"string","enum":["last_touch","first_touch","linear","time_decay","last_non_direct"],"description":"Attribution model. Defaults to `last_touch`."},"required":false,"description":"Attribution model. Defaults to `last_touch`.","name":"model","in":"query"},{"schema":{"type":"string","enum":["campaign","ad_set","ad"],"description":"Disambiguates an external id that exists at more than one level."},"required":false,"description":"Disambiguates an external id that exists at more than one level.","name":"level","in":"query"}],"responses":{"200":{"description":"The entity's full row for that window and model","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AdDetail"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"},"date_from":{"type":"string","example":"2026-08-01"},"date_to":{"type":"string","example":"2026-08-26"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing or malformed date range, or an unknown model/level","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No entity with that external id belongs to this key's profile, or it had no rows in the requested window.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/ads/{id}/placements":{"get":{"summary":"One ad's delivery split by placement","description":"Where an ad's money actually went: one row per (publisher platform × on-platform position) — Instagram Reels vs Facebook Feed vs Stories and so on — with the CTR and CPC of each.\n\nAD LEVEL ONLY. Placement is a property of a delivered ad; a campaign or ad-set id returns an empty list rather than an error, because \"this entity has no placement split\" is a shape of the data, not a bad request.\n\nRates are ratios of the SUMMED window, never a mean of daily rates. Rows are sorted by spend, descending.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The ad's platform/external id."},"required":true,"description":"The ad's platform/external id.","name":"id","in":"path"},{"schema":{"type":"string","description":"Start of the window, `YYYY-MM-DD`.","example":"2026-08-01"},"required":true,"description":"Start of the window, `YYYY-MM-DD`.","name":"date_from","in":"query"},{"schema":{"type":"string","description":"End of the window, inclusive, `YYYY-MM-DD`.","example":"2026-08-26"},"required":true,"description":"End of the window, inclusive, `YYYY-MM-DD`.","name":"date_to","in":"query"}],"responses":{"200":{"description":"One row per placement. Empty when the ad has no placement-level delivery.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AdPlacement"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"},"date_from":{"type":"string","example":"2026-08-01"},"date_to":{"type":"string","example":"2026-08-26"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing or malformed date range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/ads/signals":{"get":{"summary":"How much conversion sync is switched on","description":"A four-number answer to \"is anything wired up?\": how many signal rules are enabled, how many of those fire off a CRM pipeline-stage transition, and how many enabled export destinations each ad platform has.\n\nCOUNTS ONLY, on purpose. The rules themselves carry pixel ids, conversion actions and privacy overrides; this is the read-scope summary that says whether exports exist, not what they are configured to send. Archived rules are excluded, and so are disabled ones — the question is what is live.\n\nNo date range: this describes configuration, not a window.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The profile's signal-rule and export-destination counts","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AdsSignalsSummary"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"},"date_from":{"type":"string","example":"2026-08-01"},"date_to":{"type":"string","example":"2026-08-26"}},"required":["profile_id"]}},"required":["data","meta"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/quality/utm":{"get":{"summary":"Get UTM / tracking-health quality signals","description":"Two coverage/quality reads for the profile: the cached UTM-health badge (placement-in-source, non-channel-medium, unsubstituted-macro and missing-UTM anomaly counts, plus `refreshed_at` so a caller knows how current the answer is) and a dangling-ad-ids rollup (attributed cash sitting on synthetic touches whose ad id resolves to no known ad — a tracking-setup signal, not a bug: the credit is real, it just can't be placed in the ad hierarchy).\n\n`summary` is a CACHED read, not a live re-scan: it reflects whatever the daily utm-health-refresh cron (or someone's on-demand scan) last computed, and never triggers a write. `?days=` selects which cached window to read and is capped to the Ephemeral Buffer's retention (30 days, ADR 0014 d2) — the row this backs can never exist for a wider window, so a wider request is rejected (400) rather than silently answered with nothing. `dangling_ad_ids` reads durable tables and is not retention-bound; its own window is `?dangling_days=` (default 30) and its attribution model is `?model=` (default last_touch).","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"30","description":"Cached UTM-summary window. 1-30, default 30."},"required":false,"description":"Cached UTM-summary window. 1-30, default 30.","name":"days","in":"query"},{"schema":{"type":"string","example":"30","description":"Dangling-ad-ids lookback. Default 30."},"required":false,"description":"Dangling-ad-ids lookback. Default 30.","name":"dangling_days","in":"query"},{"schema":{"type":"string","example":"last_touch","description":"Attribution model for the dangling-ad-ids rollup. Default last_touch."},"required":false,"description":"Attribution model for the dangling-ad-ids rollup. Default last_touch.","name":"model","in":"query"}],"responses":{"200":{"description":"UTM / tracking-health quality signals","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/UtmHealth"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid parameter, or ?days= exceeds the Ephemeral Buffer window","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/quality/utm/refresh":{"post":{"summary":"Run the UTM health audit now","description":"Runs the live UTM/tracking-health scan for the profile and returns the summary it just computed. This is the write counterpart to `GET /api/v1/quality/utm`, which only ever reads whatever the daily cron last cached.\n\nSYNCHRONOUS. The scan takes roughly 8-12 seconds on a large profile and the response is terminal — there is no job id and nothing to poll. Hold the connection open.\n\nIDEMPOTENT WITHIN 10 MINUTES. If the cached summary's `refreshed_at` is younger than 10 minutes the scan is skipped and the cached row is returned with `refreshed: false`. Send `{\"force\": true}` to scan anyway; the rate limit still applies.\n\nRATE LIMIT. Beyond the per-key per-minute allowance (this route is a `heavy` 3-unit call) it carries its own bucket of 6 scans per hour per key, because one call is 8-12 seconds of database work. A 429 from that bucket carries `Retry-After`.\n\n`days` selects the scan window and is capped to the Ephemeral Buffer's retention (30 days, ADR 0014 d2) — the row this writes is keyed on it, so a wider request is rejected (400) rather than silently narrowed.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"30","description":"Scan window. 1-30, default 30."},"required":false,"description":"Scan window. 1-30, default 30.","name":"days","in":"query"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UtmHealthRefreshRequest"}}}},"responses":{"200":{"description":"The UTM-health summary. `refreshed` distinguishes a scan that ran from a cached answer inside the idempotency window.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/UtmHealthRefresh"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid body, or ?days= exceeds the Ephemeral Buffer window","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the attribution:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited — either the per-minute key allowance or this route's own 6-per-hour scan bucket. Both send `Retry-After`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"504":{"description":"`audit_timeout` — the scan did not finish inside the request budget. Retry after the `Retry-After` interval; the last cached summary remains readable from `GET /api/v1/quality/utm`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/quality/utm/anomalies":{"get":{"summary":"Get the UTM anomaly breakdown","description":"The per-issue detail behind `GET /api/v1/quality/utm`'s `high_count` / `medium_count`: what each issue is, how much traffic it affects, the top offending values observed, and the suggested fix.\n\nA CACHED read, like the badge — it never runs the scan. The scan is `POST /api/v1/quality/utm/refresh`, which is `attribution:write` precisely because it writes. Call the refresh first if you need a live answer, then read this; the refresh response deliberately returns only the summary, so the breakdown is always this second call.\n\nFRESHNESS IS ITS OWN. `refreshed_at` here dates the anomaly list, and it can be older than the badge's `refreshed_at` on the same profile: the daily cron recounts the badge cheaply and does not recompute the breakdown. `null` means the deep scan has never run for this window, which is not the same as a clean profile.\n\n`?days=` selects which cached window to read and is capped to the Ephemeral Buffer's retention (30 days, ADR 0014 d2) — the row this backs can never exist for a wider window, so a wider request is rejected (400) rather than silently answered with nothing.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"30","description":"Cached scan window to read. 1-30, default 30."},"required":false,"description":"Cached scan window to read. 1-30, default 30.","name":"days","in":"query"}],"responses":{"200":{"description":"The cached UTM anomaly breakdown","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/UtmAnomalies"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid parameter, or ?days= exceeds the Ephemeral Buffer window","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/exports/destinations":{"get":{"summary":"List conversion-export destinations","description":"Where this profile's conversions are shipped: the Meta dataset/pixel and Google Ads conversion action that are configured, whether each one is enabled, whether it will actually fire on the next run, the provider connection behind it, and its most recent delivery.\n\nRead from the same configuration the export pipeline itself loads, so this can never describe a setup the pipeline does not use.\n\nNo credentials are ever returned — access tokens are not read, let alone serialized. The ids that are returned (dataset/pixel, conversion action, ad account) are configuration identity the merchant already sees in their own settings.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The profile's configured destinations","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ExportDestinations"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the conversions:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversions:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Create a conversion-export destination","description":"Point this profile's conversions at a Meta dataset/pixel or a Google Ads conversion action.\n\n**This is half of what makes an export possible.** `POST /api/v1/exports` enqueues correctly with no destination configured and ships nothing, because the pipeline has nowhere to send to. The other half is at least one enabled rule (`POST /api/v1/exports/rules`) — there is no default mapping.\n\nThe `connection_id` must name a connection on this profile whose provider matches the kind, and the kind's own identifier is required (`meta_pixel_id` for `meta_capi`, `google_conversion_action` for `google_ads`).","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExportDestinationWriteRequest"}}}},"responses":{"201":{"description":"The destination was created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"destination":{"$ref":"#/components/schemas/ExportDestination"},"replay_queued":{"type":"boolean","description":"Whether a standard export pass was queued so this change takes effect now rather than at the next hourly sweep. It says a pass was ASKED FOR — never that anything shipped."},"replay_error":{"type":["string","null"],"description":"Why the export pass could not be queued. Null when it was. The write itself succeeded either way."}},"required":["destination","replay_queued","replay_error"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such connection on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Invalid body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/exports/ledger":{"get":{"summary":"List conversion-export deliveries","description":"The deliveries ledger — one row per (conversion × destination × rule) the export pipeline has considered, newest first. The same data the Conversion Sync Deliveries view shows, minus everything that identifies a person.\n\n**Excluded by construction**: the platform payload (which carries hashed email/phone/name plus an unhashed IP address and user agent), the Click-to-WhatsApp click id, the customer's name/email/phone, and the provider's raw error message. What remains is delivery identity and outcome: the ledger row, its canonical event id, the status ladder, the attempt count, the provider's trace id and latency, and an error CLASS.\n\n`date_from` / `date_to` window the DELIVERY ATTEMPT (`created_at`), not the source conversion's event time — the opposite of `POST /api/v1/exports`, which windows the conversion. Both are inclusive of whole UTC days.\n\nPagination is keyset: pass the returned `pagination.cursor` back as `?cursor=`. `status_counts` is a rollup over the WINDOW and deliberately ignores `?status=`, so a filtered request can still show its own share of the whole.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"2026-08-18","description":"Inclusive lower bound on the delivery's created_at (UTC day)."},"required":false,"description":"Inclusive lower bound on the delivery's created_at (UTC day).","name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-08-25","description":"Inclusive upper bound on the delivery's created_at (UTC day)."},"required":false,"description":"Inclusive upper bound on the delivery's created_at (UTC day).","name":"date_to","in":"query"},{"schema":{"type":"string","example":"meta_capi","description":"`meta_capi` or `google_ads`."},"required":false,"description":"`meta_capi` or `google_ads`.","name":"destination","in":"query"},{"schema":{"type":"string","example":"failed","description":"One of pending | processing | sent | failed | skipped | dead_letter."},"required":false,"description":"One of pending | processing | sent | failed | skipped | dead_letter.","name":"status","in":"query"},{"schema":{"type":"string","example":"50","description":"1-100, default 50."},"required":false,"description":"1-100, default 50.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Opaque keyset cursor from a previous page."},"required":false,"description":"Opaque keyset cursor from a previous page.","name":"cursor","in":"query"}],"responses":{"200":{"description":"One page of deliveries","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ExportLedgerEntry"}},"pagination":{"type":"object","properties":{"has_next":{"type":"boolean"},"cursor":{"type":"string"}},"required":["has_next"]},"status_counts":{"type":"object","additionalProperties":{"type":"number"},"example":{"sent":412,"skipped":19,"failed":3},"description":"Per-status counts over the requested window, ignoring the status filter."},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","pagination","status_counts","meta"]}}}},"400":{"description":"Invalid parameter or cursor","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the conversions:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversions:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/exports":{"post":{"summary":"Trigger a conversion export","description":"Schedule an export run for a set of conversions, either by date range (`date_from` + `date_to`, windowing the SOURCE conversion's event time) or by explicit `conversion_ids`. Exactly one of the two forms — a body carrying both is rejected rather than silently interpreted.\n\n**This queues work; it does not send.** The export pipeline performs the actual upload, which is what keeps deduplication, the privacy/HIPAA guard, the platform staleness window and the retry ceiling in one implementation. Returns `202` with a batch id; poll `GET /api/v1/exports/{id}` for what happened.\n\n**Idempotent, and the response says how.** A conversion already delivered under its canonical event id — including one that shipped via a different ingestion path for the same real booking — is reported as `already_delivered` and is never re-sent. One that a queued job is already going to pick up is `already_queued`. So repeating the same request is a no-op: the second call reports the same conversions as already queued and schedules nothing.\n\n`counts.enqueued` is a count of deliveries SCHEDULED, never of events the platform received.\n\nBlocked with `409 invalid_state` when the profile's legal gate is unsatisfied (HIPAA without a signed BAA, Platform-Safe without an accepted DPA).\n\n**`require_approval: true` puts a person between you and the send.** You still get `202`, but nothing is queued: `data` is `{status: \"awaiting_approval\", handoff}`. Give `handoff.url` to your user — the page is session-less, works on a phone and expires in 45 minutes — and poll `GET /api/v1/handoffs/{id}` until `status` leaves `pending`. That is how an agency's agent routes every Meta write on a client account past the client.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExportTriggerRequest"}}}},"responses":{"202":{"description":"The export run was scheduled — or, with `require_approval: true`, a hand-off is waiting for a human and nothing was queued.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"anyOf":[{"$ref":"#/components/schemas/ExportBatch"},{"type":"object","properties":{"status":{"type":"string","enum":["awaiting_approval"]},"handoff":{"type":"object","properties":{"id":{"type":"string"},"url":{"type":["string","null"]},"expires_at":{"type":"string"},"status":{"type":"string"},"kind":{"type":"string"}},"required":["id","url","expires_at","status","kind"]}},"required":["status","handoff"]}]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid body, or both request forms supplied","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the attribution:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"The profile's legal gate blocks exporting","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"header","x-atribu-scope-grain":"profile"}},"/api/v1/exports/{id}":{"get":{"summary":"Get a conversion-export trigger's status","description":"What one `POST /api/v1/exports` call scheduled, and where those deliveries stand now.\n\n`counts` is the immutable record of the request. `progress` is re-read from the ledger at request time, so it moves as the pipeline works: a batch whose rows are all still queued reports `{\"pending\": n}`, and a finished one reports its split across `sent` / `failed` / `skipped` / `dead_letter`.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The batch id returned by POST /api/v1/exports."},"required":true,"description":"The batch id returned by POST /api/v1/exports.","name":"id","in":"path"}],"responses":{"200":{"description":"The trigger and its live progress","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ExportBatchStatus"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the conversions:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such batch for this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversions:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/exports/destinations/{id}":{"patch":{"summary":"Update a conversion-export destination","description":"Partial update. Absent fields are left alone; `destination_config` is DEEP-MERGED rather than replaced, so a patch that sends only `signal_quality` cannot blank the `data_source` the dataset wizard wrote.\n\n`destination` is immutable — create a new destination rather than re-pointing this one at a different platform.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The destination id."},"required":true,"description":"The destination id.","name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"destination":{"type":"string","enum":["meta_capi","google_ads"],"description":"Which platform. Required on create; IMMUTABLE — a PATCH that changes it is rejected."},"connection_id":{"type":"string","format":"uuid","description":"The `data_connections` row this destination ships through. Must belong to this profile and its provider must match the kind (`meta_capi` ⇒ `meta_ads`)."},"enabled":{"type":"boolean","description":"Default true on create."},"meta_pixel_id":{"type":["string","null"],"maxLength":128,"example":"1234567890","description":"Meta dataset / pixel id. REQUIRED for `meta_capi`."},"google_conversion_action":{"type":["string","null"],"maxLength":256,"example":"customers/123/conversionActions/456","description":"Google Ads conversion action resource name. REQUIRED for `google_ads`."},"destination_config":{"type":"object","additionalProperties":{},"description":"Free-form destination settings (`signal_quality`, `relay_automatic_events`, …). DEEP-MERGED over the stored object on a PATCH, so keys written by the dataset wizard (`data_source`) and by Platform-Safe provisioning are never blanked by a partial update."}}}}}},"responses":{"200":{"description":"The updated destination","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"destination":{"$ref":"#/components/schemas/ExportDestination"},"replay_queued":{"type":"boolean","description":"Whether a standard export pass was queued so this change takes effect now rather than at the next hourly sweep. It says a pass was ASKED FOR — never that anything shipped."},"replay_error":{"type":["string","null"],"description":"Why the export pass could not be queued. Null when it was. The write itself succeeded either way."}},"required":["destination","replay_queued","replay_error"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such destination for this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Invalid body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"delete":{"summary":"Disable a conversion-export destination","description":"**Soft.** The row is set `enabled = false`, never removed: `conversion_exports.destination_config_id` references it and is the audit trail of every event ever shipped through that dataset. Re-enable with `PATCH … { \"enabled\": true }`.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The destination is disabled","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"disabled":{"type":"boolean"},"replay_queued":{"type":"boolean","description":"Whether a standard export pass was queued so this change takes effect now rather than at the next hourly sweep. It says a pass was ASKED FOR — never that anything shipped."},"replay_error":{"type":["string","null"],"description":"Why the export pass could not be queued. Null when it was. The write itself succeeded either way."}},"required":["disabled","replay_queued","replay_error"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such destination for this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Invalid body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/exports/rules":{"get":{"summary":"List conversion-export signal rules","description":"The mapping layer: which conversion definition (or pipeline stage transition) becomes which platform event, at what value, in what order. Archived rules are excluded.\n\nThe export pipeline iterates exactly these rows. A profile with none produces no export candidates at all, for any destination — there is no default mapping.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The profile's live rules","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"rules":{"type":"array","items":{"$ref":"#/components/schemas/ExportRule"}}},"required":["rules"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Create a conversion-export signal rule","description":"Map one conversion definition to one platform event. Preview it first with `POST /api/v1/exports/rules/preview`.\n\n**`value_mode: \"none\"` on a value-required Meta event is rejected** (422). Meta documents `value` + `currency` as required for `Purchase`, so such a rule can only ever emit a payload Meta refuses — one profile shipped that config for a month and lost 152 Purchases to dead letters, 306 of them permanently, because Meta's replay window is 7 days.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExportRuleWriteRequest"}}}},"responses":{"201":{"description":"The rule was created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"rule":{"$ref":"#/components/schemas/ExportRule"},"replay_queued":{"type":"boolean","description":"Whether a standard export pass was queued so this change takes effect now rather than at the next hourly sweep. It says a pass was ASKED FOR — never that anything shipped."},"replay_error":{"type":["string","null"],"description":"Why the export pass could not be queued. Null when it was. The write itself succeeded either way."}},"required":["rule","replay_queued","replay_error"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Invalid body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/exports/rules/preview":{"post":{"summary":"Preview what a signal rule would match","description":"A dry run over the last 7 days: how many source records a candidate rule would have matched, and which Meta event it would emit. Writes nothing and needs only `exports:read`.\n\n`candidate_count` is an UPPER BOUND on the rule's effect, never a forecast of deliveries — a matched record still has to clear identity, the privacy guard, the platform's staleness window and destination routing.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExportRulePreviewRequest"}}}},"responses":{"200":{"description":"What the candidate rule would have matched","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ExportRulePreview"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Invalid body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/exports/rules/{id}":{"patch":{"summary":"Update a conversion-export signal rule","description":"Partial update. `source_kind` is immutable. Set `archived_at` to retire a rule that has export history and therefore cannot be deleted.\n\nThe value-mode check runs against the MERGED rule, not the patch: a body that only sets `value_mode: \"none\"` on a Purchase rule is exactly the change that guard exists to refuse.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":160},"source_kind":{"type":"string","enum":["conversion_definition","pipeline_stage_transition"],"description":"Required on create; IMMUTABLE — a PATCH that changes it is rejected."},"source_ref_id":{"type":["string","null"],"format":"uuid","description":"REQUIRED for a `conversion_definition` rule: the `conversion_definitions` id it maps."},"source_config":{"type":"object","additionalProperties":{}},"trigger_mode":{"type":"string","enum":["event_recorded","stage_entered","stage_exited"]},"enabled":{"type":"boolean"},"archived_at":{"type":["string","null"]},"priority":{"type":"integer","minimum":1,"maximum":999},"destinations":{"type":"object","additionalProperties":{}},"platform_event_name_overrides":{"type":"object","additionalProperties":{}},"meta_custom_event_type":{"type":["string","null"]},"value_mode":{"type":"string","enum":["event_value","fixed_value","none"]},"recurring_payment_mode":{"type":"string","enum":["skip","custom_event","purchase"],"description":"Defaults to `skip` on create: a renewal is not a `Purchase`, and Meta cannot unsend one. Set `purchase` only when the business genuinely wants renewals counted as purchases at the platform."},"privacy_override":{"type":["object","null"],"additionalProperties":{}}}}}}},"responses":{"200":{"description":"The updated rule","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"rule":{"$ref":"#/components/schemas/ExportRule"},"replay_queued":{"type":"boolean","description":"Whether a standard export pass was queued so this change takes effect now rather than at the next hourly sweep. It says a pass was ASKED FOR — never that anything shipped."},"replay_error":{"type":["string","null"],"description":"Why the export pass could not be queued. Null when it was. The write itself succeeded either way."}},"required":["rule","replay_queued","replay_error"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such rule for this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Invalid body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"delete":{"summary":"Delete a conversion-export signal rule","description":"Hard delete, and it FAILS with `409 invalid_state` once the rule has export history — `conversion_exports.rule_id` is the record of every event ever sent under it. Archive instead (`PATCH` with `archived_at`), which has the same effect on the pipeline.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The rule was deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"deleted":{"type":"boolean"},"replay_queued":{"type":"boolean","description":"Whether a standard export pass was queued so this change takes effect now rather than at the next hourly sweep. It says a pass was ASKED FOR — never that anything shipped."},"replay_error":{"type":["string","null"],"description":"Why the export pass could not be queued. Null when it was. The write itself succeeded either way."}},"required":["deleted","replay_queued","replay_error"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such rule for this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"The rule has export history — archive it instead","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Invalid body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/exports/test":{"post":{"summary":"Send a Meta test event","description":"Post ONE synthetic event to the configured Meta dataset with a `test_event_code`, so it lands in Events Manager → Test Events and nowhere else. The last step of setup: destination → rule → test → export.\n\nIt carries the `atribu_src` marker a production event carries, so it actually exercises the Custom Conversion a rule created — a test that skipped it would fire the pixel and not the conversion, which is the most misleading possible result while verifying a setup.\n\nReturns Meta's response verbatim. `quality_warning` is non-null when this profile's match-quality policy would block a real event carrying only what a test carries (IP + user agent); the test still sends.\n\nCapped at 5 sends per profile per minute. Blocked with `409 invalid_state` when the profile's privacy or HIPAA legal gate refuses the payload.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExportTestSendRequest"}}}},"responses":{"200":{"description":"Meta accepted the test event","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ExportTestSend"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No enabled Meta destination on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"The privacy or legal gate blocked the test event","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Invalid body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"502":{"description":"Meta rejected the request","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/connections/pending/{provider}":{"get":{"summary":"List the accounts a pending connect can be finalized against","description":"After a connect flow bounces back with `status=pending_selection`, this returns the accounts / properties / locations the dealer must choose between, so you can render the picker in your own UI.\n\n**You only reach this when a choice is genuinely required.** When the consent resolves to exactly one candidate the callback finalizes it itself and returns `status=success`, which means CONNECTED — read the connection back, do not call this.\n\nThe provider token behind the pending selection lives for 60 minutes (`expires_at`). Past that, and for a profile with nothing pending for this provider, the answer is 404 with the same message: there is no pending selection here, start the connect flow again.\n\nScope: `attribution:write` — the same scope the `attribution_write` connect grant mints. This is a step of a connect, not an analytics read, and the candidate list is the only thing the parked token authorizes.","tags":["Connections"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["meta_ads","google_ads","google_search_console","gohighlevel"],"description":"The `connection_provider` value — the same string `GET /api/v1/connections` returns as `channel` and the same one stored on the connection. Only these four have a consent-then-pick flow; Google Calendar finalizes inside its own callback.","example":"meta_ads"},"required":true,"description":"The `connection_provider` value — the same string `GET /api/v1/connections` returns as `channel` and the same one stored on the connection. Only these four have a consent-then-pick flow; Google Calendar finalizes inside its own callback.","name":"provider","in":"path"}],"responses":{"200":{"description":"The pending selection and its candidates","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PendingConnection"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"404":{"description":"No pending connection for this provider on this key's profile — never started, already finalized, or the 60-minute window elapsed. The three are deliberately indistinguishable.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/connections/pending/{provider}/finalize":{"post":{"summary":"Finalize a pending connect with the chosen account","description":"Completes the connect: writes the connection, runs every side effect the in-app picker runs (the reporting-currency write for Meta, the per-location token exchange and conversion-definition seed for GoHighLevel), and discards the parked provider token.\n\n`candidate_id` is a candidate's `id` from `GET /api/v1/connections/pending/{provider}`, verbatim. An id the granted token cannot actually reach is 400 `invalid_parameter`.\n\n**Retries are safe.** The parked token is consumed on success, so a repeat of the SAME `candidate_id` answers 200 with `already_finalized: true` and the existing connection. A DIFFERENT `candidate_id` after the choice has been made is 409 `invalid_state`: switching accounts is a new connect, not a retry, and there is no longer a provider token to write.\n\nScope: `attribution:write`.","tags":["Connections"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["meta_ads","google_ads","google_search_console","gohighlevel"],"description":"The `connection_provider` value — the same string `GET /api/v1/connections` returns as `channel` and the same one stored on the connection. Only these four have a consent-then-pick flow; Google Calendar finalizes inside its own callback.","example":"meta_ads"},"required":true,"description":"The `connection_provider` value — the same string `GET /api/v1/connections` returns as `channel` and the same one stored on the connection. Only these four have a consent-then-pick flow; Google Calendar finalizes inside its own callback.","name":"provider","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"candidate_id":{"type":"string","description":"A candidate `id` from the pending-connection listing.","example":"act_1234567890"}},"required":["candidate_id"]}}}},"responses":{"200":{"description":"The resulting connection","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/FinalizePendingConnectionResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"`candidate_id` is not one the granted token can reach — a stale listing, or an id from a different provider.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Nothing pending and nothing connected for this provider. Start the connect flow again.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"This provider is already connected to a DIFFERENT account. Start a new connect flow to change it.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"The request body is not JSON, or `candidate_id` is missing/empty.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/connections":{"get":{"summary":"List authorized data connections","description":"Every connection on this key's profile that it is entitled to see, in two classes told apart by `kind`.\n\n`kind: \"messaging\"` — WhatsApp, Instagram, email and Google/Microsoft Calendar. For OAuth-flow-minted keys these are restricted to the connections your app holds an active authorization for; for direct admin keys, every one on the profile.\n\nEvery other kind — `ad_platform` (Meta Ads, Google Ads, Search Console), `crm` (GoHighLevel), `commerce` (Shopify), `payments` (Stripe, MercadoPago), `integration` (Notion, ManyChat) — is returned for the key's profile regardless of which connection your app was authorized for, because these are connected under a PROFILE-level grant rather than a per-connection one. This is how you confirm that a `POST /api/v1/connections/pending/{provider}/finalize` — or a connect bounce that came back `status=success` — actually produced a connection.\n\nMessaging connections are listed first, then the rest. Neither leg is otherwise ordered.\n\nEvery entry carries `sync` — when the connector last completed a run, what it is doing now, and the error text if it failed. That is the whole of a connection-health poll, so there is no separate health endpoint; narrow it with `?channel=` when you only care about one. Rows in every status are returned, `disconnected` included.","tags":["Connections"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["whatsapp","instagram","email","google_calendar","google_calendar_read","microsoft_calendar_read","meta_ads","google_ads","google_search_console","gohighlevel","shopify","stripe","mercadopago","notion","manychat","fintoc"],"description":"Filter by channel; omit for all."},"required":false,"description":"Filter by channel; omit for all.","name":"channel","in":"query"}],"responses":{"200":{"description":"Connections list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Connection"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"example":{"data":[{"id":"6a1e2f3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b","channel":"meta_ads","status":"connected","status_reason":null,"display_name":"Riqueza Digital — Ad Account","external_id":"act_1234567890","provider_subtype":null,"authorized_at":"2026-07-01T09:00:00.000Z","currency":"CLP","sync":{"status":"idle","eta_seconds":null,"current_task":null}}],"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"422":{"description":"`channel` is not one of the published values.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["whatsapp","instagram","email","calendar","calendar.manage","calendar.read","analytics:read","attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/connections/{id}":{"get":{"summary":"Get a single connection","description":"Returns one connection by id, in the same shape the list returns it — every `kind`, not only `messaging`.\n\n404 for an id on another profile, an id your app is not authorized for, and an id that does not exist. The three are deliberately indistinguishable.","tags":["Connections"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Connection","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Connection"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"404":{"description":"Connection not found or not authorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"`id` is not a UUID.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["whatsapp","instagram","email","calendar","calendar.manage","calendar.read","analytics:read","attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"delete":{"summary":"Revoke this OAuth app's authorization for a connection","description":"Marks your app's `oauth_app_authorizations` row for this connection as revoked, **and revokes every API key that authorization minted** — both the keys issued by the authorization-code exchange and the ones issued by `client_credentials` against it. `revoked_keys` says how many.\n\n**When this is your app's LAST live authorization on the profile**, older key generations that no authorization names are revoked with it. Those are keys minted before authorizations recorded which keys they issued; while your app holds another authorization here they are left alone, so revoking one connection of several never touches them.\n\n**This usually includes the key you are calling with.** The request itself completes — the key was validated before the revoke ran — but the next request with it answers 401, within seconds. A key minted under a DIFFERENT authorization of the same app (another connection on the same profile) is untouched and keeps working. To keep acting for the profile after revoking one connection, hold a separate key per authorization or mint a fresh one through `client_credentials`.\n\nDoes NOT disconnect the underlying data connection — other OAuth apps and the workspace UI continue to use it. Only messaging connections can be revoked here: any other `kind` answers 404, exactly as an unknown id does. Direct admin keys have no authorization row and reject this call with 400.\n\nIdempotent: revoking an already-revoked authorization, or one now held by a newer key generation than yours, answers 200 with `revoked_keys: 0`.","tags":["Connections"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Authorization revoked, with the number of API keys it cut","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/RevokedConnection"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Direct admin keys cannot self-revoke","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Authorization or connection not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"`id` is not a UUID.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["whatsapp","instagram","email","calendar","calendar.manage","calendar.read","analytics:read","attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/trust":{"get":{"summary":"Get attribution trust signals","description":"The three measurements behind the dashboard's Trust panel, in one document: how much of the profile's cash is traceable to an ad (`cash_coverage`), whether the instrumentation producing that number is sound (`instrumentation`), and which way the traceable half is moving (`traceable_roas_trend`).\n\nTHEY ARE ONE ANSWER, WHICH IS WHY THEY ARE ONE ROUTE. `cash_coverage` alone reads as an indictment — \"only 34% of your cash is traceable\" — when the honest reading may be \"you have great organic\". `instrumentation.tracking_healthy` is what tells the two apart, and `coverage_interpretation` states the verdict in one field.\n\nTHREE DIFFERENT WINDOWS, on purpose. `cash_coverage` uses `date_from`/`date_to` (echoed in `meta`). `instrumentation` uses `?days=` (default 30) and is capped to the Ephemeral Buffer's retention (30 days, ADR 0014 d2) because one of its signals reads that buffer — a wider request is rejected (400) rather than silently narrowed. `traceable_roas_trend` uses `?window_days=` (default 28), always ENDING TODAY and split in half: a directional read whose comparison must not jitter with a caller-chosen range.\n\nNO VERDICT IS DERIVED HERE. Scale/hold/cut guidance is a judgement over these facts and stays out of the API.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"2026-08-01","description":"Required. YYYY-MM-DD. Bounds `cash_coverage` only."},"required":true,"description":"Required. YYYY-MM-DD. Bounds `cash_coverage` only.","name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-08-26","description":"Required. YYYY-MM-DD."},"required":true,"description":"Required. YYYY-MM-DD.","name":"date_to","in":"query"},{"schema":{"type":"string","example":"last_touch","description":"Attribution model for BOTH `cash_coverage` and `traceable_roas_trend`. Default last_touch."},"required":false,"description":"Attribution model for BOTH `cash_coverage` and `traceable_roas_trend`. Default last_touch.","name":"model","in":"query"},{"schema":{"type":"string","example":"30","description":"Instrumentation-health window. 1-30, default 30."},"required":false,"description":"Instrumentation-health window. 1-30, default 30.","name":"days","in":"query"},{"schema":{"type":"string","example":"28","description":"Traceable-ROAS trend window, split in half for the recent-vs-prior comparison. 2-366, default 28."},"required":false,"description":"Traceable-ROAS trend window, split in half for the recent-vs-prior comparison. 2-366, default 28.","name":"window_days","in":"query"}],"responses":{"200":{"description":"Cash coverage, instrumentation health and the traceable-ROAS trend","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Trust"},"meta":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-08-01"},"date_to":{"type":"string","example":"2026-08-26"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid parameter, or ?days= exceeds the Ephemeral Buffer window","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/customers/{id}/payments":{"get":{"summary":"List a customer's payments with their first-payment flags","description":"Every `payment_received` conversion for one customer, newest first, each with the effective `is_first_payment` flag and the stored `manual_override` beside it.\n\n**No date range.** This is the customer's whole payment history, which is the only window in which \"is this their first payment?\" has an answer — windowing it would let a payment look like a first because the real first one fell outside the range.\n\nA customer id this key's profile does not own is 404, indistinguishable from one that does not exist. A customer who exists here but has no payments yet is 200 with `[]`.\n\nScope: `customers:read`. This is the PII tier and there is no masked variant — the response IS one named person's payment history, so a key without the scope is refused rather than served a redacted copy.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The customer's Atribu id — `customer_profiles.id`, the same value `GET /api/v1/ltv` returns as `top_customers[].customer_profile_id` and `GET /api/v1/customers` returns as `customer_profile_id`.","example":"9d5f3b1a-2c44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"The customer's Atribu id — `customer_profiles.id`, the same value `GET /api/v1/ltv` returns as `top_customers[].customer_profile_id` and `GET /api/v1/customers` returns as `customer_profile_id`.","name":"id","in":"path"}],"responses":{"200":{"description":"The customer's payments, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/CustomerPayment"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `customers:read` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such customer on this key's profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/customers/{id}/first-payment-override":{"post":{"summary":"Set or clear a payment's first-payment override","description":"Pins one payment as (or as not) the customer's first, overriding the derived flag; `manual_override: null` CLEARS the override and hands the payment back to the derivation.\n\nThe write recomputes `is_first_payment` for **every** payment of this customer, not just the named one — clearing an override can hand first-payment status back to a different row — so re-read `GET /api/v1/customers/{id}/payments` for the new picture rather than assuming only the posted payment moved.\n\n**Idempotent.** Posting the override a payment already carries writes nothing and answers 200 with `changed: false`; a real change answers `changed: true`. Saying the same thing twice is not an error, and a retry after a dropped response must not read as one.\n\n`outcome_event_id` must name a payment of the customer in the path — a payment belonging to someone else is 404, the same answer an unknown customer gets.\n\nScope: `attribution:write`. This moves LTV cohorts, the first-payment-vs-recurring revenue split and the CAC denominator; a read scope must never reach it.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The customer's Atribu id — `customer_profiles.id`, the same value `GET /api/v1/ltv` returns as `top_customers[].customer_profile_id` and `GET /api/v1/customers` returns as `customer_profile_id`.","example":"9d5f3b1a-2c44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"The customer's Atribu id — `customer_profiles.id`, the same value `GET /api/v1/ltv` returns as `top_customers[].customer_profile_id` and `GET /api/v1/customers` returns as `customer_profile_id`.","name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"outcome_event_id":{"type":"string","format":"uuid","description":"A payment's `outcome_event_id` from `GET /api/v1/customers/{id}/payments`."},"manual_override":{"type":["boolean","null"],"description":"`true` / `false` to pin the flag, `null` to clear the override. REQUIRED — omitting it is 422, because `null` already means something specific here."}},"required":["outcome_event_id","manual_override"]}}}},"responses":{"200":{"description":"The stored override, and whether this call changed anything","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/FirstPaymentOverrideResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `attribution:write` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such customer on this key's profile, or no payment with that `outcome_event_id` for that customer.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"The body is not JSON, or a field is missing or the wrong type.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/quality/attribution":{"get":{"summary":"Get attribution coverage — how many conversions can be attributed at all","description":"Splits the window's non-reversal conversions into three buckets by how much tracking they carry: a real ad identifier, an `fbclid` only, or nothing. The three PARTITION `total_conversions` — every conversion lands in exactly one — so they sum to it.\n\nThis is the CONVERSION-side companion to `GET /api/v1/quality/utm`, which scores TRAFFIC (anomalous or missing UTMs on visits, plus ad ids that resolve to no known ad). A profile can score well on one and badly on the other, which is when reading both matters; they are kept as separate routes rather than merged so neither average hides the other.\n\nUnlike `/quality/utm` this read is NOT bounded by the Ephemeral Buffer's 30-day retention: it scans `conversions`, a durable table. Any range up to 366 days is valid.\n\n`filter[<dimension>]=<operator>:<value>` (#628) narrows it to the same slice `/api/v1/breakdown` would report, so the coverage number reconciles with a filtered dashboard.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"2026-08-01"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-08-26"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string","description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`.","example":"is:Paid Social"},"required":false,"description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`.","name":"filter[channel]","in":"query"},{"schema":{"type":"string","description":"Filter by referrer: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`."},"required":false,"description":"Filter by referrer: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`.","name":"filter[referrer]","in":"query"},{"schema":{"type":"string","description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`."},"required":false,"description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`.","name":"filter[campaign]","in":"query"},{"schema":{"type":"string","description":"Filter by entry_page: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`."},"required":false,"description":"Filter by entry_page: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`.","name":"filter[entry_page]","in":"query"},{"schema":{"type":"string","description":"Filter by goal: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`."},"required":false,"description":"Filter by goal: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`.","name":"filter[goal]","in":"query"}],"responses":{"200":{"description":"Attribution coverage for the window","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AttributionQuality"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing or malformed date range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profile/freshness":{"get":{"summary":"Get when this profile's attribution data was last recomputed","description":"The freshness behind a \"data updated\" badge, and the number to poll between recomputes.\n\nIt is the ATTRIBUTION PROJECTION's watermark, **not** an ad platform connector's last sync. The two routinely differ by hours and users read \"last updated\" as *their data's* freshness, so only this one is published under that name.\n\nNo date range: this is a property of the profile, not of a window.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"Projection freshness","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProfileFreshness"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/goals/definitions":{"get":{"summary":"List the profile's outcome and conversion definitions","description":"The two catalogs a goal selector is built from, in one response — because which one to use is the thing consumers get wrong.\n\n`conversion_definitions` are the actual CONVERSION TYPES, with `revenue_type` and `attribution_eligible`. Build a goal selector from these, and match them against the `goal` filter dimension (`conversion_key` === `conversions.conversion_type`).\n\n`outcome_definitions` are CRM pipeline-stage LABELS — user-facing names such as \"Descualificado\". They name the buckets outcome COUNTS are keyed by; they carry no revenue or attribution semantics and are not goals. They are deduplicated by `event_key` (one key can have a row per CRM pipeline).\n\nThis is a READ at `analytics:read`. The write-scoped CRUD collection over `conversion_definitions` is `/api/v1/goals` and is a separate surface.\n\nNo date range: a catalogue is not windowed.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"Both definition catalogs","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/GoalDefinitions"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Create one conversion definition","description":"Defines what counts as a conversion: which raw `outcome_events.event_type` values produce it, whether its value is cash, and whether it may receive attribution credit.\n\n**Source event names are unique across the profile's definitions.** An overlap answers 409 and names the definition that already claims the event — two definitions over one event would double-count it in every report.\n\nStamps an audit row and queues one full-profile replay: a definition change re-derives the profile's conversions from its stored outcome events, so past periods change too.\n\nPreview with `POST /api/v1/goals/definitions/preview` first — `revenue_sum` there is the raw event-value total, not revenue.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversionDefinitionCreate"}}}},"responses":{"200":{"description":"The saved definition","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConversionDefinitionWriteResult"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the goals:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"A source event name is already claimed by another definition","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body, an empty condition set, or an output_channel not in the taxonomy","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["goals:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"header","x-atribu-scope-grain":"profile"}},"/api/v1/goals/outcome-counts":{"get":{"summary":"Count the profile's outcome events in a window, by type","description":"How many of each outcome type happened between two dates — the numbers a goal selector ranks and labels its options with.\n\n**Read it together with `GET /api/v1/goals/definitions`, which is a CATALOGUE and carries no numbers.** The catalogue says what a goal IS (`revenue_type`, `attribution_eligible`, the `conversion_key` a `goal` filter matches on); this says which goals actually occurred in the range the user is looking at. A picker built from one alone is wrong in a different way each time: from the catalogue only it offers goals with nothing behind them, from the counts only it has no labels and no revenue semantics.\n\n**The keys are `event_type`s, not conversion keys.** They usually coincide — a `conversion_definitions` row normally lists its own name in `source_event_names` — but they are separate vocabularies, and `conversion_definitions` remains the goal catalogue.\n\n**Counts, not conversions.** These are raw `outcome_events` rows: they are not deduplicated against `conversions`, carry no revenue, and are not attribution-filtered. For attributed outcomes and credited revenue use `/api/v1/breakdown` or `/api/v1/overview`.\n\n`filter[<dimension>]=<operator>:<value>` narrows the window to the same slice the dashboard would show. The range is inclusive of `date_to`, like every other route here.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"2026-08-01"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-08-26"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string","description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`.","example":"is:Paid Social"},"required":false,"description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`.","name":"filter[channel]","in":"query"},{"schema":{"type":"string","description":"Filter by referrer: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`."},"required":false,"description":"Filter by referrer: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`.","name":"filter[referrer]","in":"query"},{"schema":{"type":"string","description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`."},"required":false,"description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`.","name":"filter[campaign]","in":"query"},{"schema":{"type":"string","description":"Filter by entry_page: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`."},"required":false,"description":"Filter by entry_page: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`.","name":"filter[entry_page]","in":"query"},{"schema":{"type":"string","description":"Filter by goal: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`."},"required":false,"description":"Filter by goal: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry is skipped rather than rejected, matching `/overview` and `/breakdown`.","name":"filter[goal]","in":"query"}],"responses":{"200":{"description":"Outcome-event counts for the window","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/OutcomeCounts"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing or malformed date range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/filters/values":{"get":{"summary":"List the selectable values for one filter dimension","description":"Powers a filter-chip value picker: the distinct values of one dimension seen in the window, ranked by frequency.\n\nOnly the five dimensions the engine can scope AD SPEND by are offered — `channel`, `referrer`, `campaign`, `entry_page`, `goal` — and anything else is 400 `invalid_parameter`. That is the same allowlist `/api/v1/drill` enforces, and for the same reason: a filter the engine cannot scope spend by makes every spend-derived ratio silently wrong while still looking like a measurement.\n\n`campaign` values are platform campaign IDs, not campaign names — that is what the filter predicates compare against. Resolve them against `/api/v1/campaigns` for display.\n\nThe window is inclusive of `date_to`, like every other route here.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["channel","referrer","campaign","entry_page","goal"]},"required":true,"name":"dimension","in":"query"},{"schema":{"type":"string","example":"2026-08-01"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-08-26"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string","example":"50","description":"Max values (default 50, max 200)."},"required":false,"description":"Max values (default 50, max 200).","name":"limit","in":"query"}],"responses":{"200":{"description":"The dimension's values, most frequent first","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/FilterDimensionValues"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Unknown dimension, or a missing/malformed date range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/attribution/messaging/kpi":{"get":{"summary":"Get the messaging-cohort (DM Ads) KPI rollup","description":"The headline numbers for messaging-objective ads: spend, conversations started, how many were intent-scored, how many scored high-intent, and the resulting cost per high-intent conversation.\n\nSits beside `GET /api/v1/attribution/messaging`, which lists the individual messaging-attributed conversions, and reads a DIFFERENT source: the messaging cohort of the creative feature store rather than the modeled-credit ledger.\n\n**It can answer 200 with `available: false`,** and that is not an error. The source is a ROLLING aggregate over 7/14/28-day or lifetime buckets ending at the last rebuild, so a range ending more than two days in the past gets `stale_window` rather than numbers that silently describe a different period. A profile with no messaging cohort gets `no_messaging_data`. Every metric is null in both cases — null, not 0, because neither is a measurement.\n\n**There is no previous-period comparison parameter.** The rolling source has no date-bound breakdown to compute a delta from, so rather than accept and ignore `prev_from`/`prev_to`, this route does not offer them.\n\n`spend` is denominated in the profile's reporting currency and is returned as a number: this API does not format money.","tags":["Attribution"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"2026-08-01"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-08-26"},"required":true,"name":"date_to","in":"query"}],"responses":{"200":{"description":"The messaging KPI, or an explicit `available: false` with the reason it could not be computed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MessagingKpi"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"example":{"data":{"available":true,"unavailable_reason":null,"score_window":"28d","spend":"540000.00","high_intent_conversations":32,"intent_scored_conversations":118,"messaging_conversations_started":401,"cost_per_high_intent":16875},"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"400":{"description":"Missing or malformed date range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/channel-rules":{"get":{"summary":"List the profile's channel-classification rules and the channel taxonomy","description":"How this profile's traffic is labelled into channels, and the vocabulary those labels come from.\n\nRules are returned in EVALUATION order — `priority` ascending, first match wins — so a client can render the ladder without re-sorting.\n\nThe taxonomy ships in the same response because a rule cannot be read without it: `output_channel` is always one of its `channel_name` values, and a separate route would let a client draw a rule whose channel it cannot name.\n\n**Two matchers coexist.** `conditions` is the structured form the current editor writes; the flat `source_pattern` / `medium_pattern` / … columns are the older form that live rows still carry. Render whichever the row has — collapsing them makes old rules look empty.\n\nNo date range: rules are configuration, not a window.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The rules and the channel taxonomy","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ChannelRules"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Create one channel-classification rule","description":"Adds a rule to the ladder, records who added it, and queues a full-profile replay so the profile's existing sessions are re-labelled under the new ladder.\n\n**One rule per request.** There is no full-set PUT: replacing a ladder atomically needs a transactional replace in SQL, which is a decision this route deliberately does not make for the caller. Use `POST /api/v1/channel-rules/reorder` to move a rule, which IS atomic.\n\n**`output_channel` must already be in the taxonomy** — `GET /api/v1/channel-rules` returns it, and `POST /api/v1/channel-taxonomy` adds to it. A rule pointing at a channel that does not exist would classify traffic into a label nothing can render.\n\n**Preview before you save.** `POST /api/v1/channel-rules/match-count` says how much traffic one condition set catches, and `POST /api/v1/channel-rules/diff` says what a whole candidate ladder would re-label. Pass the diff's counts back as `diff_summary` and they are recorded on the audit row.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChannelRuleCreate"}}}},"responses":{"200":{"description":"The saved rule","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ChannelRuleWriteResult"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the goals:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body, an empty condition set, or an output_channel not in the taxonomy","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["goals:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"header","x-atribu-scope-grain":"profile"}},"/api/v1/channel-rules/suggestions":{"get":{"summary":"Suggest channel rules for traffic that is currently unclassified","description":"Clusters the sessions that ended up as `Direct` by (source, medium, referrer), keeps the clusters big enough to be worth a rule, and proposes a condition set for each.\n\n`sessions` is the reason to act. `suggested_channel` is a name heuristic and is routinely wrong for a house vocabulary — treat it as a pre-filled field.\n\n`suggested_conditions` is the same shape `POST /api/v1/channel-rules/match-count` accepts, so a suggestion can be counted and saved without reshaping.\n\nBacked by the 30-day Ephemeral Buffer: `days` is clamped and `window_days` reports what was actually scanned.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"30","description":"Lookback in days (default 30). Reads backed by the 30-day Ephemeral Buffer clamp it silently — always read `window_days` on the response rather than assuming this value was honoured."},"required":false,"description":"Lookback in days (default 30). Reads backed by the 30-day Ephemeral Buffer clamp it silently — always read `window_days` on the response rather than assuming this value was honoured.","name":"days","in":"query"},{"schema":{"type":"string","example":"50","description":"Cluster-size floor (default 50). Below it a cluster is noise, not a pattern worth a permanent rule."},"required":false,"description":"Cluster-size floor (default 50). Below it a cluster is noise, not a pattern worth a permanent rule.","name":"min_sessions","in":"query"},{"schema":{"type":"string","example":"5","description":"Max suggestions (default 5, max 50)."},"required":false,"description":"Max suggestions (default 5, max 50).","name":"limit","in":"query"}],"responses":{"200":{"description":"Suggested rules, largest cluster first","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ChannelRuleSuggestions"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed days / min_sessions / limit","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/channel-rules/match-count":{"post":{"summary":"Count how much traffic one candidate rule would match","description":"A dry run for a SINGLE condition set, before it is saved.\n\n**POST, and still a read at `analytics:read`.** The body is a candidate rule — a document that may never be saved — which does not fit a query string. Nothing is written; the RPC behind it is `STABLE`.\n\nBacked by the 30-day Ephemeral Buffer: `days` is clamped and `window_days` reports what was actually scanned.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"30","description":"Lookback in days (default 30). Reads backed by the 30-day Ephemeral Buffer clamp it silently — always read `window_days` on the response rather than assuming this value was honoured."},"required":false,"description":"Lookback in days (default 30). Reads backed by the 30-day Ephemeral Buffer clamp it silently — always read `window_days` on the response rather than assuming this value was honoured.","name":"days","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"conditions":{"allOf":[{"$ref":"#/components/schemas/ChannelRuleConditions"},{"description":"The candidate condition set. An EMPTY `all_of` matches every session, which is why the response's `total` is there to catch it."}]}},"required":["conditions"]}}}},"responses":{"200":{"description":"Match count over the window","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ChannelRuleMatchCount"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/channel-rules/diff":{"post":{"summary":"Project what a whole candidate rule set would re-label","description":"Replays a candidate rule LADDER over the window's existing sessions and reports what would move.\n\n**`changed` + `unmatched` do not sum to `total`.** A session that matches a rule resolving to the channel it already has is neither — \"already correct\" is the third outcome and usually the largest. Do not present the remainder as an error.\n\n**POST, and still a read at `analytics:read`** — see `/api/v1/channel-rules/match-count`.\n\nBacked by the 30-day Ephemeral Buffer: `days` is clamped and `window_days` reports what was actually scanned.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"30","description":"Lookback in days (default 30). Reads backed by the 30-day Ephemeral Buffer clamp it silently — always read `window_days` on the response rather than assuming this value was honoured."},"required":false,"description":"Lookback in days (default 30). Reads backed by the 30-day Ephemeral Buffer clamp it silently — always read `window_days` on the response rather than assuming this value was honoured.","name":"days","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"rules":{"type":"array","items":{"type":"object","properties":{"priority":{"type":"number","description":"Lower wins. Ties break on array order."},"output_channel":{"type":"string"},"conditions":{"type":["object","null"],"properties":{"all_of":{"type":"array","items":{"$ref":"#/components/schemas/ChannelRuleCondition"},"description":"EVERY condition must match. There is no `any_of`."}},"required":["all_of"]}},"required":["priority","output_channel","conditions"]},"description":"The candidate ladder, IN FULL — this is a projection of a rule SET, not of an edit to one, so omitting the untouched rules projects a profile that has none."}},"required":["rules"]}}}},"responses":{"200":{"description":"The projected re-labelling","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ChannelRuleDiff"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/goals/definitions/suggestions":{"get":{"summary":"List outcome events this profile receives but no conversion definition claims","description":"The gap between what Atribu STORES and what it COUNTS: `outcome_events.event_type` values seen in the window that no `conversion_definitions` row names in its `source_event_names`.\n\nEvery row is a candidate for `POST /api/v1/goals/definitions` (the legacy `POST /api/v1/goals` is deprecated — see its own docs). The three numbers — how often it fires, what it is worth, how many distinct people it reaches — are what justify defining it; `suggested_revenue_type`, `suggested_display_name`, `suggested_attribution_eligible` and `suggested_meta_event_name` are pre-filled form fields, not classifications.\n\nUnlike the channel-rule suggestions this one reads `outcome_events`, a DURABLE table, so the window is not clamped to 30 days.\n\nThe catalogue of definitions that already exist is `GET /api/v1/goals/definitions`.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"30","description":"Lookback in days (default 30). Reads backed by the 30-day Ephemeral Buffer clamp it silently — always read `window_days` on the response rather than assuming this value was honoured."},"required":false,"description":"Lookback in days (default 30). Reads backed by the 30-day Ephemeral Buffer clamp it silently — always read `window_days` on the response rather than assuming this value was honoured.","name":"days","in":"query"},{"schema":{"type":"string","example":"50","description":"Frequency floor (default 50). Below it an event type is noise, not a goal."},"required":false,"description":"Frequency floor (default 50). Below it an event type is noise, not a goal.","name":"min_events","in":"query"},{"schema":{"type":"string","example":"5","description":"Max suggestions (default 5, max 50)."},"required":false,"description":"Max suggestions (default 5, max 50).","name":"limit","in":"query"}],"responses":{"200":{"description":"Unmapped event types, most frequent first","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConversionDefinitionSuggestions"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed days / min_events / limit","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/goals/definitions/preview":{"post":{"summary":"Preview what a candidate conversion definition would capture","description":"How many outcome events a definition WOULD claim over the window, before it exists.\n\n**`revenue_sum` is not revenue.** It is the raw event value total; whether any of it reaches ROAS is decided by `revenue_type` and `attribution_eligible`, which this preview deliberately does not apply — a preview that pre-applied them would be a forecast, and it would be wrong the moment those fields changed.\n\n`suggested_attribution_eligible` and `suggested_meta_event_name` are pre-filled form fields guessed from `definition.source_event_names[0]`, independent of whatever `revenue_type` / `attribution_eligible` the candidate itself already sets — not a verdict.\n\n**POST, and still a read at `analytics:read`** — the body is a definition that may never be saved. Nothing is written.\n\nFor an EDIT, use `/api/v1/goals/definitions/diff`, which reports before and after.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"30","description":"Lookback in days (default 30). Reads backed by the 30-day Ephemeral Buffer clamp it silently — always read `window_days` on the response rather than assuming this value was honoured."},"required":false,"description":"Lookback in days (default 30). Reads backed by the 30-day Ephemeral Buffer clamp it silently — always read `window_days` on the response rather than assuming this value was honoured.","name":"days","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"definition":{"type":"object","properties":{"source_event_names":{"type":"array","items":{"type":"string"},"description":"The `outcome_events.event_type` values this definition would claim. Matched case-insensitively."},"revenue_type":{"type":"string","example":"cash"},"attribution_eligible":{"type":"boolean"},"lookback_window_days":{"type":"number"}},"required":["source_event_names"]}},"required":["definition"]}}}},"responses":{"200":{"description":"What the candidate definition would have captured","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConversionDefinitionPreview"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/goals/definitions/diff":{"post":{"summary":"Compare an existing conversion definition against a proposed edit","description":"Before and after, over the same window, for a change to one definition.\n\nSend `definition_id` to diff against a saved definition; omit it and every `*_before` field is **null** rather than 0 — \"this did not exist\" and \"it captured nothing\" are different facts and only the second is a measurement.\n\n`removed_event_names` is the field to surface prominently: those events stop being counted as conversions unless another definition claims them.\n\n**POST, and still a read at `analytics:read`.** Nothing is written.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"30","description":"Lookback in days (default 30). Reads backed by the 30-day Ephemeral Buffer clamp it silently — always read `window_days` on the response rather than assuming this value was honoured."},"required":false,"description":"Lookback in days (default 30). Reads backed by the 30-day Ephemeral Buffer clamp it silently — always read `window_days` on the response rather than assuming this value was honoured.","name":"days","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"definition":{"type":"object","properties":{"source_event_names":{"type":"array","items":{"type":"string"},"description":"The `outcome_events.event_type` values this definition would claim. Matched case-insensitively."},"revenue_type":{"type":"string","example":"cash"},"attribution_eligible":{"type":"boolean"},"lookback_window_days":{"type":"number"}},"required":["source_event_names"]},"definition_id":{"type":["string","null"],"description":"The saved definition being edited. Omit for a CREATE — every `*_before` then comes back null."}},"required":["definition"]}}}},"responses":{"200":{"description":"Before/after for the proposed edit","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConversionDefinitionDiff"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/workspaces/{workspaceId}/audit":{"get":{"summary":"List recorded changes to a workspace's classification settings","description":"Who changed the channel rules or the conversion definitions, and when.\n\n**`entity` is required and is an ALLOWLIST**, not a passthrough over `audit_log_events`. That table also records integration connects, member invites and onboarding milestones; serving it by arbitrary event type would turn an `analytics:read` credential into a tenant-activity feed. Today the allowlist is `channel_rules` and `conversion_definitions`.\n\n**Workspace-scoped, so a session bearer only.** An API key names exactly one profile and has no workspace to read across; it receives 403 `insufficient_scope`. Rows are narrowed inside the workspace to the profiles the caller can actually open, the same rule the other `/workspaces/{workspaceId}/…` reads follow.\n\n`metadata` is opaque — see the field description.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","enum":["channel_rules","conversion_definitions"],"description":"Which settings entity's history to return. Required."},"required":true,"description":"Which settings entity's history to return. Required.","name":"entity","in":"query"},{"schema":{"type":"string","example":"uuid","description":"Narrow to one profile. Must be a profile you can open in this workspace, else 404 — identical to a profile that does not exist."},"required":false,"description":"Narrow to one profile. Must be a profile you can open in this workspace, else 404 — identical to a profile that does not exist.","name":"profile_id","in":"query"},{"schema":{"type":"string","example":"10","description":"Max events (default 10, max 100)."},"required":false,"description":"Max events (default 10, max 100).","name":"limit","in":"query"}],"responses":{"200":{"description":"Recorded changes, most recent first","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AuditEvent"}},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing or unknown entity, or a malformed workspace id","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"An API key was presented (workspace scope needs a session bearer), or the caller lacks analytics:read","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Not a member of this workspace — byte-identical to a workspace that does not exist","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/channel-rules/{id}":{"patch":{"summary":"Update one channel-classification rule","description":"A PARTIAL update: an omitted key is left as it was, an explicit `null` blanks the column. Send only what changed.\n\nStamps an audit row and queues one full-profile replay, exactly as the create does — a rule edit changes how every past session is labelled, not only future traffic.\n\nA rule belonging to another profile answers 404, byte-identical to one that does not exist.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"uuid","description":"The rule's id."},"required":true,"description":"The rule's id.","name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChannelRuleUpdate"}}}},"responses":{"200":{"description":"The saved rule","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ChannelRuleWriteResult"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the goals:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such rule on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body, an empty condition set, or an output_channel not in the taxonomy","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["goals:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"delete":{"summary":"Delete one channel-classification rule","description":"Removes the rule, records the deletion, and queues one full-profile replay — traffic the rule used to claim falls through to whatever matches next, which is usually `Direct`.\n\nDeleting a rule that is already gone answers 404 rather than a silent success: on this surface a delete is not idempotent-by-pretence, because a caller who deleted the wrong id should find out.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"uuid","description":"The rule's id."},"required":true,"description":"The rule's id.","name":"id","in":"path"}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ChannelRuleDeleteResult"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the goals:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such rule on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body, an empty condition set, or an output_channel not in the taxonomy","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["goals:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/channel-rules/reorder":{"post":{"summary":"Swap two rules' priorities atomically","description":"Moving a rule up or down is a SWAP of two `priority` values, and it has to happen in one transaction: two sequential updates can leave the ladder with a duplicated or a missing priority if the second one fails, and the ladder is evaluated first-match-wins.\n\nBoth rules must belong to the calling profile and must be distinct. Exactly one replay is queued for the pair, not one per rule.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"rule_a_id":{"type":"string","example":"uuid"},"rule_b_id":{"type":"string","example":"uuid"}},"required":["rule_a_id","rule_b_id"]}}}},"responses":{"200":{"description":"The two rules with their new priorities","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ChannelRuleReorderResult"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the goals:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"One of the rules does not belong to this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body, an empty condition set, or an output_channel not in the taxonomy","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["goals:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/channel-taxonomy":{"post":{"summary":"Add a channel to this workspace's taxonomy","description":"Extends the vocabulary `output_channel` may point at. The new channel belongs to the WORKSPACE, not the profile — the taxonomy is shared by every profile in it, which is why `GET /api/v1/channel-rules` returns the same list for all of them.\n\n**No replay is queued and none is needed.** A channel that no rule references classifies nothing; the replay happens when a rule starts pointing at it.\n\nNames are unique across the system channels plus this workspace's own. A duplicate answers 409 rather than silently returning the existing row, because the caller asked to create one.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"channel_name":{"type":"string","minLength":1,"maxLength":120,"example":"Affiliate"},"channel_group":{"type":"string","minLength":1,"maxLength":120,"example":"Paid","description":"The bucket this channel rolls up into on grouped reports."}},"required":["channel_name","channel_group"]}}}},"responses":{"200":{"description":"The created channel","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ChannelTaxonomyWriteResult"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the goals:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"A channel with that name already exists","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body, an empty condition set, or an output_channel not in the taxonomy","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["goals:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/goals/definitions/{id}":{"patch":{"summary":"Update one conversion definition","description":"A PARTIAL update: an omitted key is left as it was. The uniqueness check runs against the definition's post-edit event names and excludes the row being edited, so re-saving an unchanged definition never conflicts with itself.\n\nStamps an audit row and queues one full-profile replay. `removed_event_names` from `POST /api/v1/goals/definitions/diff` is the field to look at before saving: those events stop being counted as conversions unless another definition claims them.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"uuid","description":"The conversion definition's id."},"required":true,"description":"The conversion definition's id.","name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversionDefinitionUpdate"}}}},"responses":{"200":{"description":"The saved definition","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConversionDefinitionWriteResult"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the goals:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such definition on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"A source event name is already claimed by another definition","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body, an empty condition set, or an output_channel not in the taxonomy","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["goals:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"delete":{"summary":"Delete one conversion definition","description":"The events it claimed stop producing conversions entirely unless another definition claims them. Stamps an audit row and queues one full-profile replay, so historical periods lose those conversions too — this is not a forward-only change.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"uuid","description":"The conversion definition's id."},"required":true,"description":"The conversion definition's id.","name":"id","in":"path"}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConversionDefinitionDeleteResult"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the goals:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such definition on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body, an empty condition set, or an output_channel not in the taxonomy","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["goals:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/quality/instrumentation-health":{"get":{"summary":"Get instrumentation health","description":"Whether the measurement itself can be trusted: is the site tracker firing, is identity stitching, are click IDs arriving, does checkout metadata reach the payment provider, and is every cash conversion FX-normalized. Six signals, a 0-100 rolled-up `score`, a `verdict`, and `missing` — the machine-readable list of what is dead or incomplete.\n\nREAD IT BEFORE ACTING ON ANY COVERAGE OR ROAS NUMBER. A low traceable-cash percentage means \"you have great organic\" when `tracking_healthy` is true and \"your tracking is broken\" when it is false, and the two call for opposite responses. `coverage_interpretation` states which in one field.\n\nTHE SAME DOCUMENT `GET /api/v1/trust` RETURNS AS ITS `instrumentation` LEG, byte for byte — one RPC, one transform. Use `/trust` when you want the coverage number and this caveat together over one date range; use this one when the WINDOW is the question, e.g. re-checking after a fix, where `/trust` would make you invent a date range and pay for two reads you do not render.\n\n`?days=` is capped to the Ephemeral Buffer's retention (30 days, ADR 0014 d2) because one of the six signals counts rows in `events_enriched`, which is pruned at 30 days. A wider request is REJECTED (400) rather than silently narrowed — the RPC would answer over 30 days either way, and a silently narrowed window is a number that lies about what it measured.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"30","description":"Window in days. 1-30, default 30. Beyond 30 is a 400, not a clamp."},"required":false,"description":"Window in days. 1-30, default 30. Beyond 30 is a 400, not a clamp.","name":"days","in":"query"}],"responses":{"200":{"description":"Instrumentation health for the profile","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Instrumentation"},"meta":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-08-01"},"date_to":{"type":"string","example":"2026-08-26"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid parameter, or ?days= exceeds the Ephemeral Buffer window","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/identity/merge-proposals":{"get":{"summary":"List suggested customer merges","description":"The review queue: pairs of customer profiles a scan believes are the same person, newest first, each with both sides' identity and the evidence for the match.\n\n**Keyset paginated.** Pass the `cursor` from `pagination.cursor` to walk older pages; an unreadable cursor starts from the beginning rather than erroring. There is no total — the queue is being resolved while you walk it, so an offset page would shift under you.\n\n`status_counts` rides alongside `data` and describes the WHOLE queue, ignoring `?status=` on purpose: it is what renders \"Pending (12) / Snoozed (3)\" while one tab is open.\n\nScope: `customers:read`. A proposal names two people with their emails and phones, and there is no useful masked variant — remove the identity and a merge review has nothing left to review.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["pending","approved","rejected","snoozed","superseded"],"description":"Defaults to `pending`."},"required":false,"description":"Defaults to `pending`.","name":"status","in":"query"},{"schema":{"type":"string","description":"1–50, default 20."},"required":false,"description":"1–50, default 20.","name":"limit","in":"query"},{"schema":{"type":"string"},"required":false,"name":"cursor","in":"query"}],"responses":{"200":{"description":"One page of proposals, plus the queue's status tallies","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MergeProposal"}},"pagination":{"type":"object","properties":{"has_next":{"type":"boolean"},"cursor":{"type":"string"}},"required":["has_next"]},"status_counts":{"$ref":"#/components/schemas/MergeProposalCounts"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","pagination","status_counts","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `customers:read` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/identity/merge-proposals/scan":{"post":{"summary":"Re-run the suggested-merge proposer","description":"Re-scans this profile's customers for pairs that look like the same person and refills the review queue.\n\nA WRITE — it inserts new proposals, supersedes older ones a better match replaced, and resurfaces snoozed ones whose window expired — but it merges nothing and moves no attribution credit, so it enqueues no recompute. The three counts are disjoint; a run that finds nothing new is three zeros, not an error.\n\nNo body: the scan is over the credential's own profile, and there is nothing else to name.\n\nScopes: `customers:read` AND `attribution:write` — the same pair every write on this surface carries.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"What the scan changed about the queue","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MergeProposalScanResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing `customers:read` or `attribution:write`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read","attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/identity/merge-proposals/{id}/preview":{"get":{"summary":"Preview what a suggested merge would move","description":"Counts, computed NOW, of what merging this proposal's two customers would move: outcome events, identifiers, touches, conversions and cash revenue, plus the three things that are lossy — a lead that would coalesce, and an email or phone collision the survivor's value wins.\n\nDeliberately not the same numbers as the proposal's `evidence`: that is what was true when the proposal was raised, this is what is true today.\n\nRead-only. Deciding is `POST …/decide`.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"A proposal's `id`, from `GET /api/v1/identity/merge-proposals`. A proposal belonging to another profile is 404, indistinguishable from one that does not exist.","example":"3f1c9b7e-2a44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"A proposal's `id`, from `GET /api/v1/identity/merge-proposals`. A proposal belonging to another profile is 404, indistinguishable from one that does not exist.","name":"id","in":"path"}],"responses":{"200":{"description":"What the merge would move","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MergeProposalPreview"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `customers:read` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such proposal on this key's profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/identity/merge-proposals/{id}/decide":{"post":{"summary":"Merge, dismiss or snooze a suggested merge","description":"`merge` performs the merge and resolves the proposal; `dismiss` resolves it without merging; `snooze` hides it for 30 days and lets the next scan resurface it.\n\n`survivor_profile_id` overrides which of the two customers survives — pass the other side's id when the proposal picked the wrong one. It must be one of the two; anything else is 422.\n\n**A merge is not reversible from this endpoint.** It moves the merged customer's outcome events, identifiers, touches and conversions onto the survivor and enqueues an attribution recompute, because moving those rows moves attribution credit. `GET /api/v1/identity/merge-history` records what happened.\n\n**Idempotent.** A proposal that is no longer `pending` was already decided: the call writes nothing and answers 200 with `changed: false` and the status it already holds. A retry after a dropped response must not read as an error, which is also why re-merging an already-merged pair is not a 409.\n\nScopes: `customers:read` AND `attribution:write`. The first because the proposal is about two named people; the second because the merge moves attribution credit — the same scope `POST /api/v1/exports` and the first-payment override carry.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"A proposal's `id`, from `GET /api/v1/identity/merge-proposals`. A proposal belonging to another profile is 404, indistinguishable from one that does not exist.","example":"3f1c9b7e-2a44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"A proposal's `id`, from `GET /api/v1/identity/merge-proposals`. A proposal belonging to another profile is 404, indistinguishable from one that does not exist.","name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"decision":{"type":"string","enum":["merge","dismiss","snooze"]},"survivor_profile_id":{"type":["string","null"],"format":"uuid","description":"Which of the proposal's two customers survives. Omit to keep the proposal's own choice. Only meaningful for `merge`."}},"required":["decision"]}}}},"responses":{"200":{"description":"What the proposal now reads, and whether this call changed it","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MergeProposalDecisionResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing `customers:read` or `attribution:write`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such proposal on this key's profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"The body is not JSON, `decision` is missing or unknown, or `survivor_profile_id` is neither of the proposal's two customers.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read","attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/identity/repair-scan":{"get":{"summary":"Audit customer profiles for missing or duplicated identifiers","description":"Scores every non-merged customer profile: how many are missing a primary email or phone, how many duplicate-email/phone groups exist, and — per profile — what a backfill would write.\n\n`auto_repairable_count` counts BACKFILLS only. Merges left the bulk repair path and go through the proposal queue instead, so `auto_merge_count` is reported for awareness and is not something `POST /api/v1/identity/repair` will act on.\n\nOffset paginated (`page`/`page_size`), because the underlying scan is a single ranked snapshot rather than a stream — unlike the proposal queue, walking it twice sees the same population.\n\nScope: `customers:read` — every row names a person.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["all","missing_email","missing_phone","social_only"],"description":"Defaults to `all`."},"required":false,"description":"Defaults to `all`.","name":"filter","in":"query"},{"schema":{"type":"string","description":"1-based. Default 1."},"required":false,"description":"1-based. Default 1.","name":"page","in":"query"},{"schema":{"type":"string","description":"1–100, default 50."},"required":false,"description":"1–100, default 50.","name":"page_size","in":"query"}],"responses":{"200":{"description":"The scan's totals and one page of profiles","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IdentityRepairScan"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `customers:read` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/identity/repair":{"post":{"summary":"Preview or apply identity repairs","description":"`action: \"preview\"` computes what would change and writes nothing; `action: \"apply\"` performs it. The two take the same body so a caller can preview exactly what it is about to apply.\n\n**Omit `operations` for the AUTO set** — every safe primary-email/primary-phone backfill the scan found. Merges are excluded from that set by design: a merge is a per-pair review decision, not a bulk repair.\n\n**Pass `operations` to name them yourself**, including `merge_profiles` — this is the path a manual merge of two specific customers takes.\n\n**A skipped operation is not an error.** An operation whose precondition moved since the preview (the value is already set, the pair is already merged) lands in `skipped` with the reason while the rest apply, which is what makes the call safe to retry.\n\nAn apply that merged anything enqueues an attribution recompute; a backfill-only apply does not, because it moves no credit.\n\nScopes: `customers:read` AND `attribution:write` — for BOTH actions. A preview is read-only but is a step in a write flow, and the response names the same people the apply will move.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"action":{"type":"string","enum":["preview","apply"]},"operations":{"type":"array","items":{"$ref":"#/components/schemas/IdentityRepairOperation"},"description":"Omit for the auto backfill set. An EMPTY array is 422 rather than a silent no-op — it almost always means a caller built the list and got zero."}},"required":["action"]}}}},"responses":{"200":{"description":"The preview (for `action: \"preview\"`) or what was applied (for `action: \"apply\"`)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"anyOf":[{"$ref":"#/components/schemas/IdentityRepairPreview"},{"$ref":"#/components/schemas/IdentityRepairApply"}]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing `customers:read` or `attribution:write`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"The body is not JSON, `action` is missing or unknown, or `operations` is empty or malformed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read","attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/identity/merge-history":{"get":{"summary":"List past merges into a customer","description":"Every merge that landed on one survivor, newest first — who was merged in, on what match, by whom, and how many events and identifiers moved.\n\n`survivor_profile_id` is REQUIRED: the history is a property of the customer that SURVIVED, and there is no profile-wide \"all merges\" question this answers.\n\n`is_currently_merged` is false for a merge that was later undone. The row stays — it is the audit record, not the current state.\n\nScope: `customers:read` — each row carries the merged customer's name, email and phone.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"`customer_profiles.id` of the customer that survived. Required."},"required":true,"description":"`customer_profiles.id` of the customer that survived. Required.","name":"survivor_profile_id","in":"query"},{"schema":{"type":"string","description":"1–100, default 25."},"required":false,"description":"1–100, default 25.","name":"limit","in":"query"}],"responses":{"200":{"description":"The merges into that customer, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MergeHistoryEntry"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"`survivor_profile_id` missing or not a UUID","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `customers:read` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such customer on this key's profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/identity/merge-preview":{"get":{"summary":"Preview merging two customers","description":"What merging these two customers WOULD move — events, identifiers, touches, conversions and cash — computed live against the current data. Nothing is written.\n\nThe same document `GET /api/v1/identity/merge-proposals/{id}/preview` returns; the difference is where the pair comes from. Use this one when YOU picked the two customers (a search result, a duplicate you spotted) rather than accepting a proposal the system raised.\n\n**Read the collision flags before merging.** `email_collision` and `phone_collision` mean the survivor's value wins and the other is dropped; `lead_coalesce` means the profile's lead count falls by one, which is correct but will show up in your reporting.\n\nTo perform the merge, call `POST /api/v1/identity/repair` with `{\"action\":\"apply\",\"operations\":[{\"type\":\"merge_profiles\", …}]}`.\n\nScope: `customers:read` — it names two people with their emails and phones.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The customer that would REMAIN."},"required":true,"description":"The customer that would REMAIN.","name":"survivor_profile_id","in":"query"},{"schema":{"type":"string","format":"uuid","description":"The customer that would be merged away."},"required":true,"description":"The customer that would be merged away.","name":"merged_profile_id","in":"query"}],"responses":{"200":{"description":"What the merge would move.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MergeProposalPreview"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter is missing, not a UUID, or names the same customer twice","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `customers:read` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Either customer belongs to another profile, or neither exists","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/identity/merges/{auditLogId}/undo":{"post":{"summary":"Undo a merge","description":"Reverse one merge and put the two customers back, using the `audit_log_id` `GET /api/v1/identity/merge-history` hands you. It identifies the MERGE, not either customer, which is why the undo hangs off it.\n\nEverything the merge moved — events, identifiers, touches, conversions, and the merged side's `lead_created` if it was collapsed — goes back, and both customers are re-attributed. No body: the merge is the URL and the actor is your credential.\n\n**Two refusals are `409`, and the message says which:** the entry is itself an undo (there is nothing to reverse), or the pair is no longer merged (someone got there first). Neither is retryable as-is.\n\nScopes: `customers:read` AND `attribution:write` — it names two people and it moves where the credit goes.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"`audit_log_id` from the merge-history read."},"required":true,"description":"`audit_log_id` from the merge-history read.","name":"auditLogId","in":"path"}],"responses":{"200":{"description":"The merge has been reversed.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/UndoMergeResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing `customers:read` or `attribution:write`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such merge on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"The entry is itself an undo, or the pair is no longer merged","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read","attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/customers/{id}/journey":{"get":{"summary":"Get one visitor's event timeline","description":"Every marketing touch and every outcome for one visitor, oldest first, as a keyset-paginated page — plus, on each touch, how much credit it received under `model`.\n\n**`customers:read`, and there is no masked variant.** Other reads on this surface can serve an `analytics:read` key by nulling their identity columns, because a conversion, a time and an amount still answer the question that was asked. A timeline IS one person's history: remove the person and the journey is what remains. A key without the scope is refused.\n\n**The credit overlay is model-dependent.** `is_credited` / `credit_share` / `credited_revenue` / `credited_conversions` are recomputed per `model`, so the same visitor renders differently under `first_touch` than under `last_touch` — that is the point, not drift. `credit_model` echoes which one answered.\n\n**Pagination is a keyset on `(event_time, source_row_id)`.** Pass `pagination.cursor` back as `cursor`; an unreadable one restarts from the beginning rather than erroring, because it is an opaque token the caller only ever got from us.\n\nThe whole-history aggregate that belongs beside this — touch count, cash revenue, time to completion, identifiers — is `GET /api/v1/customers/{id}/summary`. It is a separate route because it is not a property of a page.","tags":["Customers"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The VISITOR's id: `customer_profiles.id` when the visitor is identified, otherwise the anonymous id. It is exactly the `visitor_id` `GET /api/v1/visitors` returns, and equally the `customer_profile_id` `GET /api/v1/customers` returns — pass either back verbatim.\n\nThe two kinds are told apart by SHAPE: a visitor id is a UUID if and only if it names a customer. A UUID that belongs to another profile is `404`, never `403` — a status that separated \"not yours\" from \"not a thing\" would be an id oracle. An unknown anonymous id is a 200 with an empty timeline, because an anonymous id is not a record whose existence could be disclosed.","example":"9d5f3b1a-2c44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"The VISITOR's id: `customer_profiles.id` when the visitor is identified, otherwise the anonymous id. It is exactly the `visitor_id` `GET /api/v1/visitors` returns, and equally the `customer_profile_id` `GET /api/v1/customers` returns — pass either back verbatim.\n\nThe two kinds are told apart by SHAPE: a visitor id is a UUID if and only if it names a customer. A UUID that belongs to another profile is `404`, never `403` — a status that separated \"not yours\" from \"not a thing\" would be an id oracle. An unknown anonymous id is a 200 with an empty timeline, because an anonymous id is not a record whose existence could be disclosed.","name":"id","in":"path"},{"schema":{"type":"string","description":"The opaque `pagination.cursor` from the previous page."},"required":false,"description":"The opaque `pagination.cursor` from the previous page.","name":"cursor","in":"query"},{"schema":{"type":"string","example":"50","description":"Events per page (default 50, max 100)."},"required":false,"description":"Events per page (default 50, max 100).","name":"limit","in":"query"},{"schema":{"type":"string","enum":["last_touch","first_touch","linear","time_decay","last_non_direct"],"description":"Which attribution model the credit overlay is computed under. Defaults to `last_touch`. An unknown value is 400 — never a silent fallback, which would answer with numbers under a model nobody asked for."},"required":false,"description":"Which attribution model the credit overlay is computed under. Defaults to `last_touch`. An unknown value is 400 — never a silent fallback, which would answer with numbers under a model nobody asked for.","name":"model","in":"query"}],"responses":{"200":{"description":"One page of the visitor's timeline","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/JourneyEvent"}},"pagination":{"type":"object","properties":{"has_next":{"type":"boolean"},"cursor":{"type":"string"}},"required":["has_next"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","pagination","meta"]}}}},"400":{"description":"Unknown `model`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the customers:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such customer in this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/customers/{id}/summary":{"get":{"summary":"Get one visitor's whole-history totals and identifiers","description":"The header that belongs above `GET /api/v1/customers/{id}/journey`: how many touches the visitor has ever had, how much cash they have ever produced, how long they took to reach the goal, their lead score, and the email/phone the profile knows them by.\n\n**`customers:read`, and there is no masked variant** — the same refusal the timeline makes, and for a stronger reason here: `identifiers` is the person.\n\n**No date range, deliberately.** Every number is whole-history. Windowing them would make `time_to_completion_seconds` answerable only when the first touch AND the conversion both happened to fall inside the range asked for, which is a different measurement wearing the same name.\n\n**It is a separate route rather than a field on the timeline's first page.** The internal console handler this replaces returned these values only when no cursor was sent and zeros otherwise, so their meaning depended on which page you asked for.\n\n`goal` picks which conversion type \"completion\" means; it matches `conversion_definitions[].conversion_key` from `GET /api/v1/goals/definitions`.","tags":["Customers"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The VISITOR's id: `customer_profiles.id` when the visitor is identified, otherwise the anonymous id. It is exactly the `visitor_id` `GET /api/v1/visitors` returns, and equally the `customer_profile_id` `GET /api/v1/customers` returns — pass either back verbatim.\n\nThe two kinds are told apart by SHAPE: a visitor id is a UUID if and only if it names a customer. A UUID that belongs to another profile is `404`, never `403` — a status that separated \"not yours\" from \"not a thing\" would be an id oracle. An unknown anonymous id is a 200 with an empty timeline, because an anonymous id is not a record whose existence could be disclosed.","example":"9d5f3b1a-2c44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"The VISITOR's id: `customer_profiles.id` when the visitor is identified, otherwise the anonymous id. It is exactly the `visitor_id` `GET /api/v1/visitors` returns, and equally the `customer_profile_id` `GET /api/v1/customers` returns — pass either back verbatim.\n\nThe two kinds are told apart by SHAPE: a visitor id is a UUID if and only if it names a customer. A UUID that belongs to another profile is `404`, never `403` — a status that separated \"not yours\" from \"not a thing\" would be an id oracle. An unknown anonymous id is a 200 with an empty timeline, because an anonymous id is not a record whose existence could be disclosed.","name":"id","in":"path"},{"schema":{"type":"string","example":"payment_received","description":"Conversion type for `time_to_completion_seconds`. Defaults to `payment_received`. Only this one field depends on it — the counts and revenue are goal-independent."},"required":false,"description":"Conversion type for `time_to_completion_seconds`. Defaults to `payment_received`. Only this one field depends on it — the counts and revenue are goal-independent.","name":"goal","in":"query"}],"responses":{"200":{"description":"The visitor's whole-history totals and identifiers","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/CustomerSummary"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the customers:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such customer in this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/dm-ads/board":{"get":{"summary":"The DM Ads board — ranking, KPIs, funnel and creative patterns","description":"One document with everything the click-to-message board renders: the messaging-cohort ad ranking (with creative, per-day series and cohort reason codes), the six headline KPIs against the previous period of equal length, the messaging funnel and its 30-day trend, and the creative patterns mined across this profile's messaging ads.\\n\\n**A different resource from `GET /api/v1/dm-ads`**, not a superset of it: that route is a bounded ad ranking and stays cheap. This one runs five reads and is priced `heavy`.\\n\\n`sample_conversations` on each ad requires `customers:read` in addition to `campaigns:read`; without it the field is `null` (never `[]`).\\n\\n**`funnel_trend` ignores the requested range.** It is always the 30 days ending at `date_to`, because `rpc_dm_ads_funnel_trend` generates that series itself. **`patterns` is scored on the rolling `28d` creative window**, not on `date_from`..`date_to`, because `creative_feature_store` has no custom-range grain.\\n\\n**Nothing here is causal.** `patterns[].lift_vs_baseline` is an association and carries its own confidence interval; the per-ad `roas` is Atribu-attributed cash, and `cost_per_high_intent` is spend over an LLM-classified conversation count.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"2026-03-01"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-03-25"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string","description":"Ads returned. Default 25, max 50.","example":"50"},"required":false,"description":"Ads returned. Default 25, max 50.","name":"limit","in":"query"}],"responses":{"200":{"description":"The board document","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DmAdsBoard"},"meta":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-03-01"},"date_to":{"type":"string","example":"2026-03-25"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing or malformed date range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the campaigns:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"500":{"description":"One of the five reads failed — quote `request_id`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/commerce/products":{"get":{"summary":"List commerce products","description":"The merchant's product catalogue with each product's variants nested, keyset-paginated on `(updated_at, product_id)` ASCENDING. Requires `commerce:read`.\n\nThis is the PULL half of the commerce sync: a `catalog.updated` webhook is a tickle that tells you a pull is worth making, never the data itself. Pass the newest `updated_at` you have seen as `updated_since` and walk forward; the ascending order is what keeps a cursor valid no matter how many products change while you page.\n\nDeactivated products are included (`active: false`) so a mirror learns about removals.\n\n**Every amount is an exact decimal string**, never a JSON number (ADR 0018).","tags":["Commerce"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"2026-08-25T18:15:49.247850Z","description":"Return only products Atribu wrote at or after this instant. Omit for the whole catalogue."},"required":false,"description":"Return only products Atribu wrote at or after this instant. Omit for the whole catalogue.","name":"updated_since","in":"query"},{"schema":{"type":"string","example":"50","description":"Rows per page (default 10, max 100)."},"required":false,"description":"Rows per page (default 10, max 100).","name":"limit","in":"query"},{"schema":{"type":"string","description":"The opaque `pagination.cursor` from the previous page. An unreadable cursor restarts from the beginning rather than erroring — it is a token the caller only ever got from us."},"required":false,"description":"The opaque `pagination.cursor` from the previous page. An unreadable cursor restarts from the beginning rather than erroring — it is a token the caller only ever got from us.","name":"cursor","in":"query"}],"responses":{"200":{"description":"Product list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/CommerceProduct"}},"pagination":{"type":"object","properties":{"has_next":{"type":"boolean"},"cursor":{"type":"string"}},"required":["has_next"]},"meta":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-03-01"},"date_to":{"type":"string","example":"2026-03-25"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","pagination","meta"]},"example":{"data":[{"product_id":"gid://shopify/Product/7834501298","provider":"shopify","external_id":"gid://shopify/Product/7834501298","name":"Colchón Ortopédico Queen","description":null,"slug":"colchon-ortopedico-queen","image_url":"https://cdn.shopify.com/s/files/1/0001/products/queen.jpg","active":true,"currency":"CLP","updated_at":"2026-08-25T18:15:49.247850Z"}],"pagination":{"has_next":false},"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"403":{"description":"Missing the commerce:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["commerce:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/commerce/orders":{"get":{"summary":"Look an order up","description":"Find a shopper's orders by order number, email or phone — the 'where is my order' lookup that an `order.status.changed` webhook sends you to. Requires `commerce:read`.\n\n**At least one of `order_ref`, `email` or `phone` is required.** A call with none answers 400: this is a lookup on a named shopper's behalf, not a dump of the merchant's order history.\n\n**No customer identity is returned.** You already hold whichever contact you searched with, so echoing it back tells you nothing — but the same row is reachable by order number, and returning a contact there would turn a guessable reference into a lookup of the person behind it.\n\nResults are keyset-paginated on `(ordered_at, order_id)` DESCENDING, newest first.","tags":["Commerce"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"#1003","description":"The order number the shopper can see, with or without the leading `#`, or the store's own order id."},"required":false,"description":"The order number the shopper can see, with or without the leading `#`, or the store's own order id.","name":"order_ref","in":"query"},{"schema":{"type":"string","description":"Normalised before matching."},"required":false,"description":"Normalised before matching.","name":"email","in":"query"},{"schema":{"type":"string","example":"+56911112222","description":"Matched on the last nine digits, so a WhatsApp sender id and a checkout field written in different formats still meet."},"required":false,"description":"Matched on the last nine digits, so a WhatsApp sender id and a checkout field written in different formats still meet.","name":"phone","in":"query"},{"schema":{"type":"string","example":"20","description":"Rows per page (default 10, max 100)."},"required":false,"description":"Rows per page (default 10, max 100).","name":"limit","in":"query"},{"schema":{"type":"string","description":"The opaque `pagination.cursor` from the previous page. An unreadable cursor restarts from the beginning rather than erroring — it is a token the caller only ever got from us."},"required":false,"description":"The opaque `pagination.cursor` from the previous page. An unreadable cursor restarts from the beginning rather than erroring — it is a token the caller only ever got from us.","name":"cursor","in":"query"}],"responses":{"200":{"description":"Order list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/CommerceOrder"}},"pagination":{"type":"object","properties":{"has_next":{"type":"boolean"},"cursor":{"type":"string"}},"required":["has_next"]},"meta":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-03-01"},"date_to":{"type":"string","example":"2026-03-25"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","pagination","meta"]}}}},"400":{"description":"None of order_ref / email / phone was supplied","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the commerce:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["commerce:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/workspaces/{workspaceId}/pii-access-log":{"get":{"summary":"Audit access to this workspace's customer/visitor PII","description":"One row per services/api request whose route required a PII scope (`customers:read` / `visitors:read`) — who read it, when, through which route, and what the response status was. Durable for 365 days by default.\n\n**Workspace-scoped, so a session bearer only.** An API key names exactly one profile and has no workspace to read across; it receives 403 `insufficient_scope`.\n\n**Operators only, by construction.** This route's own gate is `customers:read` — the workspace-grain PII scope, which a `guest` session never holds. There is no separate operator check: the scope requirement already is one. Reading this log is itself a `customers:read` request and writes its own row into the log it just served.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","example":"2026-08-01"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-09-01"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string","example":"50","description":"Max rows (default 50, max 200)."},"required":false,"description":"Max rows (default 50, max 200).","name":"limit","in":"query"},{"schema":{"type":"string","description":"The opaque `pagination.cursor` from the previous page."},"required":false,"description":"The opaque `pagination.cursor` from the previous page.","name":"cursor","in":"query"}],"responses":{"200":{"description":"PII access records, most recent first","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PiiAccessLogEntry"}},"pagination":{"type":"object","properties":{"has_next":{"type":"boolean"},"cursor":{"type":"string"}},"required":["has_next"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","pagination","meta"]}}}},"400":{"description":"Missing/malformed date_from or date_to, or a range wider than 366 days","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"An API key was presented (workspace scope needs a session bearer), or the caller lacks customers:read (a guest never holds it at workspace grain)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Not a member of this workspace — byte-identical to a workspace that does not exist","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/conversations":{"get":{"summary":"List the unified inbox's conversations","description":"One page of conversations across every connected channel — WhatsApp, Instagram and email — newest first by `last_message_at`. `channel` narrows to one; `search` matches `external_user_id` (phone / IGSID / Gmail thread id) as a substring. Keyset paginated: pass `pagination.cursor` from the previous response to walk older pages.","tags":["Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["all","whatsapp","instagram","email"]},"required":false,"name":"channel","in":"query"},{"schema":{"type":"string"},"required":false,"name":"search","in":"query"},{"schema":{"type":"string","description":"The opaque `pagination.cursor` from the previous page."},"required":false,"description":"The opaque `pagination.cursor` from the previous page.","name":"cursor","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"One page of conversations","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationsListResponse"},"example":{"data":[{"status":"open","service_window_expires_at":"2026-08-28T14:00:00.000Z","last_message_at":"2026-08-28T02:00:00.000Z","last_inbound_at":"2026-08-28T02:00:00.000Z","inbound_count":4,"outbound_count":3,"customer_profile_id":"b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e","contact_name":"Camila Rojas","contact_phone":"+56912345678","linked_customer_is_payer":true,"linked_customer_name":"Camila Rojas","ctwa_clid":null,"referral_source_type":null,"referral_source_id":null,"referral_headline":null,"referral_body":null,"referral_media_type":null,"referral_media_url":null,"referral_ads_context":null,"ad_name":null,"campaign_name":null,"human_handoff_at":null,"ig_username":null,"profile_pic_url":null,"email_subject":null,"email_correspondent":null,"last_message":{"direction":"inbound","text":"¿Tienen stock del modelo queen?","sent_at":"2026-08-28T02:00:00.000Z"}}],"pagination":{"has_next":false},"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"403":{"description":"Missing `conversations:read`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversations:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversations/{id}/messages":{"get":{"summary":"Get a conversation's message thread","description":"Messages ordered newest-first by `message_timestamp`, so the UI can render a thread and load older messages by paging with `before`. `conversation_messages` is shared by all three channels — WhatsApp, Instagram and email — so this is the one read for a thread regardless of which channel the conversation is on.","tags":["Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","example":"3f1c9b7e-2a44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","name":"id","in":"path"},{"schema":{"type":"string","description":"ISO timestamp cursor — return messages strictly before this."},"required":false,"description":"ISO timestamp cursor — return messages strictly before this.","name":"before","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"The message thread","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationMessagesResponse"}}}},"403":{"description":"Missing `conversations:read`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Conversation not found (or belongs to another profile)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversations:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Send a reply on a conversation","description":"Text-only in v1. Branches on the conversation's channel: WhatsApp Cloud API, Instagram Graph API (with the HUMAN_AGENT tag outside the 24h window, up to 7 days), or a threaded Gmail send. Writes the sent message to `conversation_messages` — the same table `GET .../messages` reads — and stamps `human_handoff_at` with the moment the first person took the conversation over.","tags":["Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","example":"3f1c9b7e-2a44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string","minLength":1,"maxLength":4000}},"required":["text"]}}}},"responses":{"200":{"description":"The sent message","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"Missing account/recipient, or an unsupported channel","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing `conversations:write`, or Meta's Human Agent review is still pending","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Conversation not found (or belongs to another profile)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"The WhatsApp/Instagram messaging window has expired","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"The mailbox's daily email send cap has been reached","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"503":{"description":"Email integration is not configured on this deployment","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversations:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversations/{id}/organic-matches":{"get":{"summary":"Get organic posts + ads related to a conversation","description":"Organic posts (Instagram/Facebook) and ads whose creative embedding is most semantically similar to this conversation's *substantive* inbound messages (greetings excluded). `has_embeddings=false` means the daily embeddings cron has not reached this conversation yet (poll); `has_embeddings=true, has_substantive=false` is terminal — a greeting-only, low-signal conversation with no meaningful vector to match.","tags":["Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","example":"3f1c9b7e-2a44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","name":"id","in":"path"},{"schema":{"type":"integer","minimum":1,"maximum":20},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Related organic posts and ads","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationOrganicMatchesResponse"}}}},"403":{"description":"Missing `conversations:read`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Conversation not found (or belongs to another profile)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversations:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversations/{id}/labels":{"get":{"summary":"List an email conversation's Gmail labels","description":"The connected mailbox's Gmail labels (`users.labels.list`, under `gmail.modify`), for an \"apply label\" picker on an email conversation's thread. 400 if `{id}` is not an email-channel conversation.","tags":["Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","example":"3f1c9b7e-2a44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","name":"id","in":"path"}],"responses":{"200":{"description":"The mailbox's labels","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationLabelsResponse"}}}},"400":{"description":"Not an email conversation, or its email account/thread is missing","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing `conversations:read`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Conversation not found (or belongs to another profile)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversations:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Add/remove Gmail labels on an email conversation's thread","description":"`users.threads.modify`, under `gmail.modify`. At least one of `add_label_ids` / `remove_label_ids` is required.","tags":["Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","example":"3f1c9b7e-2a44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"addLabelIds":{"type":"array","items":{"type":"string","minLength":1},"maxItems":50},"removeLabelIds":{"type":"array","items":{"type":"string","minLength":1},"maxItems":50}}}}}},"responses":{"200":{"description":"Applied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplyLabelsResponse"}}}},"400":{"description":"Not an email conversation, or neither add nor remove list was given","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing `conversations:write`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Conversation not found (or belongs to another profile)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"503":{"description":"Email integration is not configured on this deployment","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversations:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversations/{id}/review-proposals":{"get":{"summary":"List a conversation's pending contact-extraction review proposals","description":"Pending `dm_contact_extraction_review` rows for this conversation — an email/phone the customer wrote in a DM, or a username-match against an existing customer — awaiting a human decision. Drives the approval banner above the message thread. Deciding a proposal (`approve`/`reject`) is a write and ships in a follow-up (`conversations:write`).","tags":["Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","example":"3f1c9b7e-2a44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","name":"id","in":"path"}],"responses":{"200":{"description":"Pending review proposals","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationReviewProposalsResponse"}}}},"403":{"description":"Missing `conversations:read`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Conversation not found (or belongs to another profile)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversations:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Approve or reject a pending contact-extraction review proposal","description":"`rpc_apply_extraction_review`. Approving an email/phone-extraction proposal find-or-creates the canonical customer_profile via `resolve_customer_profile`; approving a username-match proposal binds the IGSID to the already-proposed customer_profile_id.","tags":["Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","example":"3f1c9b7e-2a44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"review_id":{"type":"string","format":"uuid"},"action":{"type":"string","enum":["approve","reject"]}},"required":["review_id","action"]}}}},"responses":{"200":{"description":"The decision outcome","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DecideReviewProposalResponse"}}}},"403":{"description":"Missing `conversations:write`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The review proposal was not found for this conversation","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversations:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/inbox/pending-reviews-count":{"get":{"summary":"Count the profile's pending review-queue proposals","description":"Workspace-level (profile-scoped) summary of the `dm_contact_extraction_review` queue: the total pending count, and the conversation that owns the OLDEST pending proposal — for a \"N pending\" pill that jumps straight to it. The count is an exact `COUNT`, not a capped row read, so it keeps moving past 1,000.","tags":["Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"Pending review count","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PendingReviewsCountResponse"}}}},"403":{"description":"Missing `conversations:read`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversations:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/unlinked-payments":{"get":{"summary":"List recent cash payments with no linked Instagram conversation","description":"Recent cash acquisition payments whose payer has no Instagram identity — the payment-side reconciliation worklist. An operator who recognizes a payment as coming from a DM links it to the conversation (a write, follow-up). Sits beside the inbox's own \"link to customer\" affordance; both invalidate the same client cache key.","tags":["Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":200},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Unlinked payments","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnlinkedInstagramPaymentsResponse"}}}},"403":{"description":"Missing `conversations:read`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversations:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/search-conversations":{"get":{"summary":"Typeahead search over Instagram conversations","description":"Instagram conversations matching `q` against the resolved `@username`, name or raw IGSID — the reverse picker for the unlinked-payments worklist (given a payment, find the conversation it came from).","tags":["Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","maxLength":120},"required":false,"name":"q","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":50},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Matching conversations","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstagramConversationSearchResponse"}}}},"403":{"description":"Missing `conversations:read`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversations:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversations/{id}/draft":{"post":{"summary":"Save a reply as a Gmail draft instead of sending it","description":"Email-channel only. Threads the draft onto the conversation's Gmail thread (`users.drafts.create`, under `gmail.modify`) so it shows up as a reply in Gmail's Drafts.","tags":["Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","example":"3f1c9b7e-2a44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string","minLength":1,"maxLength":20000}},"required":["text"]}}}},"responses":{"200":{"description":"The created draft's id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SaveDraftResponse"}}}},"400":{"description":"Not an email conversation, or its email account/thread is missing","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing `conversations:write`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Conversation not found (or belongs to another profile)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Could not determine a recipient for this email thread","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"503":{"description":"Email integration is not configured on this deployment","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversations:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversations/{id}/link-customer":{"post":{"summary":"Link an Instagram conversation to a customer (human-driven DM→payment link)","description":"Stitches this Instagram conversation's IGSID identity to the given `customer_profile_id` via `rpc_link_conversation_to_customer`, then forces a full re-attribution so the deterministic and modeled credit catch up. Instagram-channel only — a non-Instagram conversation, or a customer that already holds a different floating identity the identity-safety guardrails refuse to merge, answers `action: \"noop\"` / `\"needs_confirmation\"` rather than an error.","tags":["Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","example":"3f1c9b7e-2a44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"customer_profile_id":{"type":"string","format":"uuid"}},"required":["customer_profile_id"]}}}},"responses":{"200":{"description":"The link outcome","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkCustomerResponse"}}}},"403":{"description":"Missing `conversations:write`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Conversation not found (or belongs to another profile)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversations:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversations/{id}/unlink-customer":{"post":{"summary":"Undo a prior manual DM→payment link","description":"Reverses a merge or an attach via `rpc_unlink_conversation_from_customer`, then forces a full re-attribution so the decoupled cash conversion reverts to direct/organic. No body — the conversation already carries its IGSID and current customer.","tags":["Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","example":"3f1c9b7e-2a44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","name":"id","in":"path"}],"responses":{"200":{"description":"The unlink outcome","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnlinkCustomerResponse"}}}},"403":{"description":"Missing `conversations:write`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Conversation not found (or belongs to another profile)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversations:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversations/{id}/resolve-user":{"get":{"summary":"Resolve an Instagram conversation's IGSID to @username/name/photo","description":"A GET with real side effects — a live Meta User Profile API call plus a 7-day cached write-through to `conversations.channel_data.resolved_user` — which is why it requires `conversations:write` despite the verb. Instagram-channel only; every other channel (and a channel-data-less Instagram row) answers `data: null`. A Graph failure falls back to the stale cache, even past 7 days, so the UI never flashes back to the raw IGSID.","tags":["Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","example":"3f1c9b7e-2a44-4e7a-9a11-3b6f2e8d7c10"},"required":true,"description":"A conversation's `id`, from `GET /api/v1/conversations`. A conversation belonging to another profile is 404, indistinguishable from one that does not exist.","name":"id","in":"path"}],"responses":{"200":{"description":"The resolved user, or null","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResolveConversationUserResponse"}}}},"403":{"description":"Missing `conversations:write`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Conversation not found (or belongs to another profile)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["conversations:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/handoffs/{id}":{"get":{"summary":"Poll a hand-off you handed to a human","description":"Some steps only a human can take — granting an OAuth consent, picking a Meta ad account, signing a DPA, paying for a plan, approving a write you proposed. Each of those is minted as a **hand-off**: you get a URL, give it to your user, and poll here until they are done.\n\n**There is no `POST /api/v1/handoffs`.** A hand-off is created by the route for the thing it hands off, which is the only place that knows what completing it means. This is the one poll they all share.\n\n**An expired hand-off is 200 with `status: \"expired\"`, never 404.** Expiry is derived at read time, so there is no window in which a dead hand-off still reads `pending`, and no ambiguity between 'this one lapsed, mint another' and 'that id is wrong'.\n\n**`url` is returned only while `status` is `pending`.** Whoever holds it can complete the hand-off — the page is session-less by design, so your user does not need an Atribu account or an open session — which is exactly why a settled hand-off stops returning it.\n\nA hand-off minted by another workspace is 404, the same answer an id that never existed gets.\n\nScope: `analytics:read` — the mint is what needs authority, not the question about it.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The `handoff_id` the minting call returned. Not the handle the URL carries — that is the human's capability, this is the agent's.","example":"0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21"},"required":true,"description":"The `handoff_id` the minting call returned. Not the handle the URL carries — that is the human's capability, this is the agent's.","name":"id","in":"path"}],"responses":{"200":{"description":"The hand-off, in whatever state it is in","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PendingAction"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"404":{"description":"No hand-off with that id for this workspace — never created, malformed, or minted by someone else. The three are deliberately indistinguishable. An EXPIRED hand-off is not one of them: that is a 200.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/connections/{provider}/handoff":{"post":{"summary":"Hand a provider connect to a human and get a URL for them","description":"Your agent cannot grant an OAuth consent — the provider's screen needs a person in a browser. This mints a **hand-off**: give the returned `url` to your user, then poll `GET /api/v1/handoffs/{id}` until `status` is no longer `pending`.\n\n**The page is session-less.** Whoever holds the link completes it — signed out, on a phone, without an Atribu account. That is the point: the human you are handing this to is often the client, not the operator.\n\n**Two ways it completes, and both answer `status: \"completed\"`.** When the consent resolves to exactly one account, `result.connection_id` names the connection that was written — read it with `GET /api/v1/connections/{id}`. When it exposes several, `result.pending_selection` says so and a human choice is still owed: finish it with `GET /api/v1/connections/pending/{provider}` and `POST …/finalize` (the parked provider token lives 60 minutes). The hand-off is `completed` either way, because the part only a browser could do is done.\n\n**A consent that fails answers `status: \"failed\"`** with `result.reason` — `provider_denied` when the user said no, `no_candidates` when the account has nothing to connect (not a retry: they have to be granted access at the provider first), `origin_not_allowed` when the `return_url` you supplied is not registered for this profile's app.\n\n**The URL is single-use and short-lived.** It stops working at `expires_at` (45 minutes) and the moment the hand-off settles; the page then shows the human what state it is in rather than a dead link. Mint another if they missed it — minting twice is two hand-offs, not an error.\n\nScope: `attribution:write` — the same scope the connect picker declares, so one credential can start a connect and finish it.","tags":["Connections"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["meta_ads","google_ads","google_search_console","gohighlevel","stripe","mercadopago"],"description":"The `connection_provider` value — the same string `GET /api/v1/connections` returns as `channel`, so a connection you read back can be fed straight into a re-connect. `shopify` is absent on purpose and permanently: a Shopify install begins inside Shopify (the App Store listing, HMAC-verified) and there is no consent Atribu can start on the merchant's behalf.","example":"meta_ads"},"required":true,"description":"The `connection_provider` value — the same string `GET /api/v1/connections` returns as `channel`, so a connection you read back can be fed straight into a re-connect. `shopify` is absent on purpose and permanently: a Shopify install begins inside Shopify (the App Store listing, HMAC-verified) and there is no consent Atribu can start on the merchant's behalf.","name":"provider","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"profile_id":{"type":"string","description":"Optional assertion, not a selector. Your credential already names a profile; sending a DIFFERENT one is 404. Omit it unless you want the request to fail loudly when your key is scoped somewhere you did not expect."},"return_url":{"type":"string","description":"Where to send the human when the consent finishes, instead of back to the hand-off page. Must be an absolute http(s) URL whose ORIGIN your app can receive the bounce on for this profile — any origin from your app's own `redirect_uris` works automatically (Atribu is the OAuth provider, so 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. That check runs when the human clicks, so an origin outside both sets surfaces on your poll as `status: \"failed\"`, `result.reason: \"origin_not_allowed\"`, never as a 400 here. It receives `?connect=<slug>&status=…&profile_id=…`, the same query the consumer connect bounce has always used.","example":"https://app.example.com/onboarding/atribu"}}}}}},"responses":{"200":{"description":"The hand-off. `url` is what you give the human; `id` is what you poll. Identical to what `GET /api/v1/handoffs/{id}` returns.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PendingAction"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"`provider` is not one of the six, or `return_url` is not an absolute http(s) URL.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"`profile_id` names a profile this credential is not scoped to. The same answer a profile that does not exist gets — deliberately indistinguishable.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"The request body is present but is not a JSON object.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/legal/dpa/handoff":{"post":{"summary":"Mint a signing hand-off for the Data Processing Agreement","description":"Your agent cannot agree to a contract on a customer's behalf, so it mints a **hand-off**: a session-less URL for a person, an id to poll, and an expiry. The page renders the same click-wrap the console shows — scroll to the bottom, give a name and an email, accept — and records the signer's email, IP, user agent, timestamp and document version.\n\n**A document that is already accepted answers `status: \"completed\"` immediately**, with `result: { already_accepted: true }` and no URL. That is the idempotent path: re-running a checklist must never ask a customer to sign twice.\n\nPoll `GET /api/v1/handoffs/{id}` until `status` leaves `pending`, then read the record back from `GET /api/v1/conversion-sync/legal`.\n\nScope: `exports:write` — the legal half of the Conversion Sync surface, whose read is `exports:read`.","tags":["Conversion Sync"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The hand-off. `status: \"pending\"` with a `url` to hand over, or `status: \"completed\"` with `result.already_accepted` when it was signed already.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PendingAction"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"403":{"description":"Missing the exports:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/legal/dpa/accept":{"post":{"summary":"Accept the Data Processing Agreement as the signed-in owner or admin","description":"Records a click-wrap acceptance **for the signed-in person making the call**. This is the console's own write, exposed so acceptance has one implementation rather than several.\n\n**An API key or an MCP token is refused with 403.** A machine credential — a delegated key most of all, which is an app acting for someone else's workspace — is not a person who can agree to a contract, and a signature record that could have been produced by one would be worth nothing. An `atb_user_` MCP token is refused too, and deliberately: it names a real person but it is the credential they minted FOR AN AGENT, so accepting with one is an agent signing on their behalf. Only a browser session says a person is present. Mint a hand-off with `POST /api/v1/legal/{document}/handoff` and give the URL to a human instead.\n\nSession callers must hold the workspace role `owner` or `admin`; a guest reaching the profile through a profile membership alone is refused for the same reason.\n\n**Idempotent**: accepting an already-accepted document writes nothing and answers `already_accepted: true` with the ORIGINAL timestamp.\n\nThe body is optional. Its one field, `document_version`, names WHICH of the shipped texts you displayed — the full agreement at `/dpa`, or the shorter platform-safe click-wrap that gates `privacy_mode`. It is checked against an allowlist and never recorded as given: the column says which agreement a customer accepted, and it cannot do that if the customer picks the value. Omit it to accept the current text.","tags":["Conversion Sync"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegalAcceptRequest"}}}},"responses":{"200":{"description":"The acceptance record","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/LegalAcceptance"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"403":{"description":"The credential is an API key (a machine cannot sign), the caller is not a workspace owner/admin, or the exports:write scope is missing","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"document_version is not a version of this document that this build ships","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/legal/baa/handoff":{"post":{"summary":"Mint a signing hand-off for the combined DPA & HIPAA BAA","description":"Your agent cannot agree to a contract on a customer's behalf, so it mints a **hand-off**: a session-less URL for a person, an id to poll, and an expiry. The page renders the same click-wrap the console shows — scroll to the bottom, give a name and an email, accept — and records the signer's email, IP, user agent, timestamp and document version.\n\n**A document that is already accepted answers `status: \"completed\"` immediately**, with `result: { already_accepted: true }` and no URL. That is the idempotent path: re-running a checklist must never ask a customer to sign twice.\n\nPoll `GET /api/v1/handoffs/{id}` until `status` leaves `pending`, then read the record back from `GET /api/v1/conversion-sync/legal`.\n\nScope: `exports:write` — the legal half of the Conversion Sync surface, whose read is `exports:read`.","tags":["Conversion Sync"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The hand-off. `status: \"pending\"` with a `url` to hand over, or `status: \"completed\"` with `result.already_accepted` when it was signed already.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PendingAction"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"403":{"description":"Missing the exports:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/legal/baa/accept":{"post":{"summary":"Accept the combined DPA & HIPAA BAA as the signed-in owner or admin","description":"Records a click-wrap acceptance **for the signed-in person making the call**. This is the console's own write, exposed so acceptance has one implementation rather than several.\n\n**An API key or an MCP token is refused with 403.** A machine credential — a delegated key most of all, which is an app acting for someone else's workspace — is not a person who can agree to a contract, and a signature record that could have been produced by one would be worth nothing. An `atb_user_` MCP token is refused too, and deliberately: it names a real person but it is the credential they minted FOR AN AGENT, so accepting with one is an agent signing on their behalf. Only a browser session says a person is present. Mint a hand-off with `POST /api/v1/legal/{document}/handoff` and give the URL to a human instead.\n\nSession callers must hold the workspace role `owner` or `admin`; a guest reaching the profile through a profile membership alone is refused for the same reason.\n\n**Idempotent**: accepting an already-accepted document writes nothing and answers `already_accepted: true` with the ORIGINAL timestamp.\n\nThe body is optional. Its one field, `document_version`, names WHICH of the shipped texts you displayed — the full agreement at `/dpa`, or the shorter platform-safe click-wrap that gates `privacy_mode`. It is checked against an allowlist and never recorded as given: the column says which agreement a customer accepted, and it cannot do that if the customer picks the value. Omit it to accept the current text.","tags":["Conversion Sync"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegalAcceptRequest"}}}},"responses":{"200":{"description":"The acceptance record","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/LegalAcceptance"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"403":{"description":"The credential is an API key (a machine cannot sign), the caller is not a workspace owner/admin, or the exports:write scope is missing","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"document_version is not a version of this document that this build ships","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/workspaces/{workspaceId}/checkout-session":{"post":{"summary":"Mint a Stripe Checkout hand-off for a plan upgrade","description":"Your agent cannot enter a card, so it mints a **hand-off**: a Stripe Checkout URL to give a person, an id to poll, and an expiry. The human pays in any browser, Stripe's webhook settles the hand-off, and your next poll answers `status: \"completed\"` with `result.subscription` naming the new tier and status.\n\n**Already on that plan?** The hand-off comes back `completed` with `result: { no_change: true }` and no URL — never a second subscription. `trialing` counts as being on the plan: a mid-trial workspace already holds that subscription, and buying it again would create a second one rather than converting the trial.\n\n**API keys cannot call this.** The route is workspace-scoped and a key is minted for one profile, so it has no workspace membership to check — `withApiAuth` refuses it with a 403 before the handler runs. A signed-in session and an MCP user token (`atb_user_`) both reach it: each names a PERSON with a workspace role, and the MCP `start_plan_upgrade` tool rides the same route. The caller must hold the workspace role `owner` or `admin`: an analyst reads numbers and does not commit the workspace to a recurring charge.\n\n**Downgrades are not a checkout.** A lower tier is a Customer Portal change; opening a second subscription for it would double-bill. `upgrade_available` on the subscription read lists only what is above the current plan for exactly this reason.\n\nThe Checkout URL is never returned after the hand-off settles — a spent Stripe session renders an error page, and handing one back would advertise a dead payment link.\n\nScope: `analytics:read`, matching the subscription read this answers. The scope is not the gate here; the workspace role is.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"uuid"},"required":true,"name":"workspaceId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutSessionRequest"}}}},"responses":{"200":{"description":"The hand-off. `status: \"pending\"` with the Checkout `url` to hand over, or `status: \"completed\"` with `result.no_change` when the plan is already active.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PendingAction"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"403":{"description":"The credential is an API key (single-profile by design), or the caller is not a workspace owner/admin","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or you are not an active member","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Unknown plan or interval","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"502":{"description":"Stripe refused the request — most often because the plan's price does not exist in this Stripe mode. The message names the missing lookup key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"503":{"description":"Billing is not configured on this deployment; no session was created","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/instagram/competitor-sources":{"get":{"summary":"List tracked competitor handles and hashtags","description":"Every competitor source on this profile, enabled and disabled, newest first (max 200).\n\n`media_count` rides along so a list render needs no second call per row. Read its field note before treating `null` as zero.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The profile's competitor sources","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/IgCompetitorSource"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Track a competitor handle or hashtag","description":"`value` is normalised before it is stored — lowercased, with a leading `@` or `#` removed — so two spellings of the same handle collide on the unique constraint rather than becoming near-duplicates.\n\n**The charset is enforced, and that is not cosmetic.** Characters outside Meta's username/hashtag policy flow into the Graph `fields` expression unencoded; Meta answers 400, the nightly sync swallows that as \"0 rows synced\" and CLEARS `last_sync_error` — so a malformed source looks healthy forever.\n\nCapped per profile (50 by default): Business Discovery and Hashtag Search are APP-level rate limited, so one tenant with hundreds of handles starves every other tenant's nightly sync.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"kind":{"type":"string","enum":["handle","hashtag"]},"value":{"type":"string","minLength":1,"maxLength":100},"display_label":{"type":["string","null"],"maxLength":200}},"required":["kind","value"]}}}},"responses":{"201":{"description":"The created source","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IgCompetitorSource"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Value empty after normalisation, or outside the charset for its kind","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"Already tracked, or the per-profile cap is reached","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/competitor-sources/{id}":{"patch":{"summary":"Enable, disable or relabel a competitor source","description":"Supply at least one of `enabled` or `display_label`. A source belonging to another profile is 404, indistinguishable from one that does not exist.\n\n`media_count` is `null` on this response — it is not recomputed for a metadata edit, and a stale number would be worse than no number.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"enabled":{"type":"boolean"},"display_label":{"type":["string","null"],"maxLength":200}}}}}},"responses":{"200":{"description":"The updated source","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IgCompetitorSource"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such row on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"delete":{"summary":"Stop tracking a competitor source","description":"Deletes the source. Captured `ig_competitor_media` snapshots are removed with it by the FK cascade.\n\n**404 on an id this profile does not own**, unlike the internal handler this replaces, which answered `{ ok: true }` either way and so made a typo'd id indistinguishable from a successful removal.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"deleted":{"type":"boolean","enum":[true]}},"required":["deleted"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such row on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/competitor-sources/{id}/media":{"get":{"summary":"Latest captured posts for one competitor source","description":"The most recent snapshot day only, up to 25 posts, most-liked first. An empty array means this source has never returned a post — there is no snapshot day to read.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The latest snapshot","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/IgCompetitorMedia"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/competitor-sources/refresh":{"post":{"summary":"Queue a competitor-research sync now","description":"Enqueues ONE `competitor_research` job for this profile's Instagram connection — the same message the nightly tick sends. Enqueue-only: the pipeline-worker is woken by LISTEN/NOTIFY, so nothing here waits on Meta.\n\nTwo guards, and both answer rather than silently no-op: a job already pending for the connection returns `already_queued`, and a refresh inside the 60-second per-connection cooldown is a 429 carrying `Retry-After` in seconds.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"Enqueued, or already queued","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"status":{"type":"string","enum":["enqueued","already_queued"]}},"required":["status"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"This profile has no Instagram connection","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/short-links":{"get":{"summary":"List Instagram short links","description":"The profile's `/r/<slug>` links, newest first (max 200). `base_url` is the origin the slugs resolve against — the main app's, never the API's — so a caller can build the full URL without hardcoding a host.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The profile's short links","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/IgShortLink"}},"base_url":{"type":"string","example":"https://www.atribu.app"}},"required":["links","base_url"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Mint an Instagram short link","description":"`destination_url` passes a scheme + host ALLOWLIST, not just a URL shape: http(s) only, and no loopback, private, link-local or cloud-metadata host. This value is later redirected to and embedded in a public page, and a plain URL check accepts `javascript:`, `data:` and `169.254.169.254`.\n\n**Idempotent per media.** With `ig_media_id`, a second call returns the existing reservation (patched to the destination and UTMs you sent) with `reused: true` and **200**, not 201. Pinning a `slug` that disagrees with that reservation is a 409 instead — merge-on-conflict is the semantic for an unspecified slug only.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"destination_url":{"type":"string","maxLength":2048,"format":"uri"},"ig_media_id":{"type":["string","null"],"minLength":1,"maxLength":200},"utm_campaign":{"type":["string","null"],"maxLength":200},"utm_content":{"type":["string","null"],"maxLength":200},"slug":{"type":"string","description":"4–30 characters of `a-z`, `0-9`, `-` or `_`."}},"required":["destination_url"]}}}},"responses":{"200":{"description":"An existing per-media reservation was returned (`reused: true`)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"link":{"$ref":"#/components/schemas/IgShortLink"},"short_url":{"type":"string"},"reused":{"type":"boolean"}},"required":["link","short_url","reused"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"201":{"description":"The created short link","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"link":{"$ref":"#/components/schemas/IgShortLink"},"short_url":{"type":"string"},"reused":{"type":"boolean"}},"required":["link","short_url","reused"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"`destination_url` is not an allowed public http(s) URL","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"The requested slug is taken, or conflicts with the media's reservation","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/short-links/follow-proxy":{"post":{"summary":"Mint a Follow Proxy link for an Instagram handle","description":"A short link whose destination is `instagram.com/<handle>`, for the Website URL field of a Meta Website-Traffic ad. The click carries UTMs and stamps the visitor cookie on the redirect, which is what later joins a Follow→DM event to the ad that paid for it.\n\nIdempotent per (profile, handle): re-issuing returns the existing slug with `reused: true` and 200 rather than minting a second one.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ig_username":{"type":"string","minLength":1,"maxLength":40,"description":"With or without a leading `@`; normalised to lowercase.","example":"atribu.app"}},"required":["ig_username"]}}}},"responses":{"200":{"description":"The existing Follow Proxy link (`reused: true`)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"slug":{"type":"string"},"destination_url":{"type":"string"},"short_url":{"type":"string"},"reused":{"type":"boolean"}},"required":["slug","destination_url","short_url","reused"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"201":{"description":"The created Follow Proxy link","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"slug":{"type":"string"},"destination_url":{"type":"string"},"short_url":{"type":"string"},"reused":{"type":"boolean"}},"required":["slug","destination_url","short_url","reused"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"`ig_username` is not a valid Instagram handle","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/bio-media/{media_id}":{"patch":{"summary":"Show or hide one post on the hosted bio page","description":"Toggles `ig_media.visible_on_bio` for one synced post, which is what the hosted `/bio/<slug>` page renders from.\n\n`media_id` is the META media id (a numeric string), not an Atribu uuid. The path is `/bio-media/` rather than a longer path under `/instagram/media/` on purpose: that prefix is the live Graph proxy, and a rewrite capturing a longer path under it would be one ordering mistake away from forwarding that route here.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"media_id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"visible":{"type":"boolean"}},"required":["visible"]}}}},"responses":{"200":{"description":"The post's new visibility","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"ig_media_id":{"type":"string"},"visible_on_bio":{"type":"boolean"}},"required":["ig_media_id","visible_on_bio"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such row on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/accounts":{"get":{"summary":"List connected Instagram accounts","description":"Every Instagram binding on this profile, newest first, each carrying the health of the `data_connections` row behind it so a settings render needs no second call.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The profile's Instagram accounts","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/IgAccount"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/accounts/{account_id}":{"delete":{"summary":"Disconnect an Instagram account","description":"Deletes the binding (cascading to the conversations tied to it) and marks the `data_connections` row `disconnected` rather than deleting it — the row stays as the audit record, which is the convention every provider's disconnect follows.\n\nIf this binding owned the account's automations and the same Instagram account is still connected from another workspace, the oldest surviving healthy binding is promoted, so comment-to-DM and the AI agent resume there instead of going dark.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"account_id","in":"path"}],"responses":{"200":{"description":"Disconnected","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"status":{"type":"string","enum":["disconnected"]}},"required":["status"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such row on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/accounts/{account_id}/media":{"get":{"summary":"Page the account's synced posts","description":"Locally-synced `ig_media` rows for one account, newest first — this is a DATABASE read, not a Graph call, so it is safe to poll.\n\n`next_cursor` is the last row's `posted_at`; pass it as `?after=`. `connection_status` and `last_synced_at` ride along because the post picker polls this endpoint while a sync runs — a separate call for them would be a poll per poll.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"account_id","in":"path"},{"schema":{"type":"string","description":"1–60, default 24."},"required":false,"description":"1–60, default 24.","name":"limit","in":"query"},{"schema":{"type":"string","description":"The previous page's `next_cursor` (an ISO `posted_at`)."},"required":false,"description":"The previous page's `next_cursor` (an ISO `posted_at`).","name":"after","in":"query"}],"responses":{"200":{"description":"One page of posts, plus the connection's sync state","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"media":{"type":"array","items":{"$ref":"#/components/schemas/IgAccountMedia"}},"next_cursor":{"type":["string","null"]},"connection_status":{"type":["string","null"]},"last_synced_at":{"type":["string","null"]}},"required":["media","next_cursor","connection_status","last_synced_at"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such row on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/accounts/{account_id}/resume-comment-to-dm":{"post":{"summary":"Clear a tripped comment-to-DM breaker","description":"The breaker trips when comment-triggered DMs exceed the per-minute cap for an account — a viral comment thread would otherwise burn Meta's messaging quota and trip anti-spam.\n\n**Both halves are cleared.** The send path short-circuits on a Redis key BEFORE it looks at `comment_to_dm_paused_until`, so clearing only the column would answer 200 and leave the account paused.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"account_id","in":"path"}],"responses":{"200":{"description":"Resumed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"resumed":{"type":"boolean","enum":[true]}},"required":["resumed"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such row on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/accounts/{account_id}/comment-triggers":{"get":{"summary":"List an account's comment-to-DM triggers","description":"Oldest first, the order the console renders them in.\n\n**Account-keyed, and a different resource from `/api/v1/instagram/triggers`**, which is keyed on `connection_id` and is served by the Next lane. Both write the same rows and validate against the same limits; which one to call depends on whether you hold a connection id or an Instagram account id.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"account_id","in":"path"}],"responses":{"200":{"description":"The account's triggers","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/IgCommentTrigger"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such row on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Create a comment-to-DM trigger","description":"A trigger fires when a comment on this account matches `keyword` under `keyword_match_mode`, sending `opening_message` as a DM (and optionally `public_comment_reply` on the comment itself).\n\n`post_id_allowlist` restricts it to named posts; omit it and every post is in scope. Sends are capped per account per minute by the circuit breaker — see the resume route.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"account_id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IgCommentTriggerCreate"}}}},"responses":{"201":{"description":"The created trigger","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IgCommentTrigger"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such row on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"A trigger with this keyword already exists on the account","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/accounts/{account_id}/comment-triggers/{trigger_id}":{"patch":{"summary":"Update a comment-to-DM trigger","description":"Every field is optional; supply at least one. Fields you omit are left as they are — this is a patch, not a replace.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"account_id","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"trigger_id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"keyword":{"type":"string","minLength":1,"maxLength":500},"keyword_match_mode":{"type":"string","enum":["contains","exact","regex"]},"case_sensitive":{"type":"boolean"},"post_id_allowlist":{"type":["array","null"],"items":{"type":"string","minLength":1,"maxLength":64},"maxItems":200},"opening_message":{"type":"string","minLength":1,"maxLength":2000},"public_comment_reply":{"type":["string","null"],"maxLength":2000},"agent_context_hint":{"type":["string","null"],"maxLength":1000},"enabled":{"type":"boolean"}}}}}},"responses":{"200":{"description":"The updated trigger","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IgCommentTrigger"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such row on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"delete":{"summary":"Delete a comment-to-DM trigger","description":"Removes the trigger. Conversations it already started are unaffected.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"account_id","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"trigger_id","in":"path"}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"deleted":{"type":"boolean","enum":[true]}},"required":["deleted"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such row on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/ctm-health":{"get":{"summary":"Click-to-Message attribution health","description":"How many of this profile's Instagram conversations were attributed DETERMINISTICALLY — an exact DM-ad referral or a Follow Proxy click — against every conversation in the window, plus the counts each other attribution path contributed.\n\n`severity` is `empty` when there is nothing to measure, `critical` when a webhook delivery has failed recently (whatever the ratio), otherwise `ok` at ≥60% deterministic, `warn` at ≥30%, `critical` below. **`empty` is not a bad score**; it means the window holds no Instagram conversations at all.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"1–90, default 14."},"required":false,"description":"1–90, default 14.","name":"window_days","in":"query"}],"responses":{"200":{"description":"Attribution-health counts and the derived severity","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IgCtmHealth"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/ctm-inventory":{"get":{"summary":"MESSAGES-objective ads and their ManyChat mapping","description":"Every MESSAGES-objective Meta ad on this profile, flagged with whether ManyChat holds a flow mapping for it. An unmapped ad is one whose conversations cannot be attributed deterministically, which is what makes this list actionable rather than descriptive.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The inventory and its mapped/unmapped split","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IgCtmInventory"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/follower-attribution":{"get":{"summary":"Which ads produced followers","description":"Expected followers per ad with a confidence band, plus the window's total follow events.\n\n**Read `coverage_note` before quoting these numbers.** Follow events are captured through a welcome-DM heuristic, so only followers who DM the business are scored — Meta does not expose the `follow` webhook field to Instagram Business Login apps, and silent followers are absent from this attribution entirely. The caveat is served with the data rather than left to a UI to remember.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"1–90, default 30."},"required":false,"description":"1–90, default 30.","name":"window_days","in":"query"}],"responses":{"200":{"description":"Per-ad follower attribution for the window","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IgFollowerAttribution"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/identity-reviews":{"get":{"summary":"Pending DM identity-match reviews","description":"The queue of proposed matches between an Instagram DM sender and a customer profile, each carrying the evidence (username, and the email or phone extracted from the thread).\n\nScope: `instagram`. These rows name people; the scope is the gate.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"1–200, default 50."},"required":false,"description":"1–200, default 50.","name":"limit","in":"query"}],"responses":{"200":{"description":"Pending reviews","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IgIdentityReviews"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/identity-reviews/{review_id}/decide":{"post":{"summary":"Approve or reject a DM identity match","description":"Approving STITCHES the DM's identity onto the customer profile — it merges identities and is what unlocks modeled attribution from a DM conversation to a payment. Rejecting closes the review and leaves both identities alone.\n\nThe review's ownership is re-checked against your profile before the merge runs: the underlying routine keys only on the review id, so the scope gate alone would not be enough.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"review_id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"action":{"type":"string","enum":["approve","reject"]}},"required":["action"]}}}},"responses":{"200":{"description":"What the decision did","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IgIdentityReviewDecision"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such row on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/sync":{"post":{"summary":"Queue an Instagram sync now","description":"Enqueues this profile's Instagram sync jobs — the same messages the daily tick sends. **202, because the work is accepted, not done**: the pipeline-worker is woken by LISTEN/NOTIFY and nothing here waits on Meta. Poll `GET /api/v1/instagram/accounts/{account_id}/media` for `connection_status` and `last_synced_at` to see it finish.\n\nZero jobs enqueued is a 409, not a 202 with a count of nothing: it means a sync is already running for the connection.\n\nScope: `instagram`.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"202":{"description":"Accepted — jobs enqueued","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IgSyncEnqueued"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the `instagram` scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"A sync is already in progress for this connection","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"not_found"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/workspaces/{workspaceId}/api-usage":{"get":{"summary":"Per-key API usage for a workspace","description":"What each of this workspace's API keys did over a date range: request count, the 4xx/5xx split, p50/p95 latency and when it was last seen.\n\n**Workspace-scoped, so a session bearer or an MCP user token only.** An API key names exactly one profile and has no workspace to read across; it receives 403 `insufficient_scope`.\n\nCounts are within one flush window of live (the writer batches, at most 5 seconds or 200 requests behind). The 4xx/5xx SHARE is unaffected by that lag — a delayed batch delays successes and errors alike.\n\nRows older than 90 days are pruned, so a window reaching further back returns only what is still retained.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","example":"2026-08-01"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-09-01"},"required":true,"name":"date_to","in":"query"}],"responses":{"200":{"description":"Per-key usage, busiest key first","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"keys":{"type":"array","items":{"$ref":"#/components/schemas/ApiKeyUsage"}},"date_from":{"type":"string"},"date_to":{"type":"string"}},"required":["keys","date_from","date_to"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing/malformed date_from or date_to, or a range wider than 366 days","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"An API key was presented — workspace scope needs a session bearer","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Not a member of this workspace — byte-identical to a workspace that does not exist","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/overview":{"get":{"summary":"Get dashboard overview","description":"Spend, clicks, attributed revenue, ROAS, outcome counts and the cash /\nfirst-payment / recurring / refund / dispute legs for a window, alongside the\nimmediately-preceding window of equal length.\n\n`grain` selects ONE of two response shapes — they never appear together:\n\n- `grain=total` (the default) — `data.current` and `data.previous`, each one\n  aggregate row.\n- `grain=day` — `data.days` and `data.previous_days`, one row per calendar day\n  with the same legs plus a `date`. Days are zero-filled, so a day with no\n  activity is present and reads `0`.\n\n`first_payment_customers` and `recurring_customers` are `count(DISTINCT)`:\nsumming them across `grain=day` rows does NOT reproduce the `grain=total`\nnumber, because one customer can appear on several days. Use `grain=total`\nfor a CAC denominator.\n\n`spend`, `roas` and `clicks` are `null` — never `\"0\"` / `0` — when the engine\ncould not scope spend to the requested filter; `spend_available` says so and is\nthe reason WHY. A measured zero spend still arrives as `\"0\"` with\n`spend_available: true`, and the two are different facts.\n\n`filter[<dimension>]=<operator>:<value>`, e.g. `filter[channel]=is:Paid Social` — the\nsame parser `/campaigns`, `/breakdown` and `/drill` accept (#628). Operators: `is`,\n`is_not`, `contains`, `not_contains`. A malformed entry is silently skipped rather than\nrejected; a dimension the engine cannot scope spend by makes `spend`/`spend_available`\nanswer honestly rather than 400ing (see `spend_available`'s own description).\n\nScope: `analytics:read`.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":false,"name":"model","in":"query"},{"schema":{"type":"string","enum":["total","day"],"description":"`total` (default) returns `current`/`previous`; `day` returns `days`/`previous_days`. Any other value is a 400."},"required":false,"description":"`total` (default) returns `current`/`previous`; `day` returns `days`/`previous_days`. Any other value is a 400.","name":"grain","in":"query"},{"schema":{"type":"string","description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated.","example":"is:Paid Social"},"required":false,"description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated.","name":"filter[channel]","in":"query"},{"schema":{"type":"string","description":"Filter by referrer: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated."},"required":false,"description":"Filter by referrer: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated.","name":"filter[referrer]","in":"query"},{"schema":{"type":"string","description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated."},"required":false,"description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated.","name":"filter[campaign]","in":"query"},{"schema":{"type":"string","description":"Filter by entry_page: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated."},"required":false,"description":"Filter by entry_page: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated.","name":"filter[entry_page]","in":"query"},{"schema":{"type":"string","description":"Filter by goal: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated."},"required":false,"description":"Filter by goal: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated.","name":"filter[goal]","in":"query"}],"responses":{"200":{"description":"Overview data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OverviewResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/ltv":{"get":{"summary":"Get the LTV / cohort dashboard","description":"Returns cash-only customer lifetime value: per-cohort retention curves, a repeat-purchase histogram (days from first to second payment), cumulative LTV curves (average + best cohort), a monthly first-payment-vs-recurring revenue split, top customers by LTV, and headline KPIs (avg LTV, repeat rate, median days to second payment). Sourced from paid conversions only — pipeline/won-but-unpaid deals are excluded, same as everywhere else `revenue_type='cash'` is the rule (see CLAUDE.md).","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"2026-01-01"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-08-25"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string","example":"12","description":"How many months out to track each cohort. Clamped 1–24, default 12."},"required":false,"description":"How many months out to track each cohort. Clamped 1–24, default 12.","name":"cohort_depth_months","in":"query"}],"responses":{"200":{"description":"LTV dashboard data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LtvResponse"}}}},"400":{"description":"Invalid date range or cohort_depth_months","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/timeseries":{"get":{"summary":"Get daily timeseries data","description":"Returns daily cash revenue and ad spend.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"}],"responses":{"200":{"description":"Timeseries data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string"},"cash_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"spend":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["date","cash_revenue","spend"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"example":{"data":[{"date":"2026-08-01","cash_revenue":"1245000.00","spend":"312500.00"},{"date":"2026-08-02","cash_revenue":"980000.00","spend":"298750.00"}],"meta":{"date_from":"2026-08-01","date_to":"2026-08-02","profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/realtime":{"get":{"summary":"Get live visitor count","description":"Returns the number of visitors currently online (active in last 5 minutes).","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"Realtime data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"visitors_online":{"type":"number"}},"required":["visitors_online"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["realtime:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/channels":{"get":{"summary":"Conversions and revenue by traffic source","description":"Returns attributed conversions and credit-weighted revenue per channel.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Breakdown data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"value":{"type":"string","description":"The dimension value (e.g. 'Paid Social')."},"conversions":{"type":"number","description":"Attributed conversions credited to this dimension value."},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Credit-weighted attributed revenue for this dimension value."}},"required":["value","conversions","revenue"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/pages":{"get":{"summary":"Conversions and revenue by landing page","description":"Returns attributed conversions and credit-weighted revenue per page.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Breakdown data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"value":{"type":"string","description":"The dimension value (e.g. 'Paid Social')."},"conversions":{"type":"number","description":"Attributed conversions credited to this dimension value."},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Credit-weighted attributed revenue for this dimension value."}},"required":["value","conversions","revenue"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/countries":{"get":{"summary":"Conversions and revenue by country","description":"Returns attributed conversions and credit-weighted revenue per country.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Breakdown data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"value":{"type":"string","description":"The dimension value (e.g. 'Paid Social')."},"conversions":{"type":"number","description":"Attributed conversions credited to this dimension value."},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Credit-weighted attributed revenue for this dimension value."}},"required":["value","conversions","revenue"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/devices":{"get":{"summary":"Conversions and revenue by device type","description":"Returns attributed conversions and credit-weighted revenue per device.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Breakdown data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"value":{"type":"string","description":"The dimension value (e.g. 'Paid Social')."},"conversions":{"type":"number","description":"Attributed conversions credited to this dimension value."},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Credit-weighted attributed revenue for this dimension value."}},"required":["value","conversions","revenue"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/browsers":{"get":{"summary":"Conversions and revenue by browser","description":"Returns attributed conversions and credit-weighted revenue per browser.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Breakdown data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"value":{"type":"string","description":"The dimension value (e.g. 'Paid Social')."},"conversions":{"type":"number","description":"Attributed conversions credited to this dimension value."},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Credit-weighted attributed revenue for this dimension value."}},"required":["value","conversions","revenue"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/os":{"get":{"summary":"Conversions and revenue by operating system","description":"Returns attributed conversions and credit-weighted revenue per os.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Breakdown data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"value":{"type":"string","description":"The dimension value (e.g. 'Paid Social')."},"conversions":{"type":"number","description":"Attributed conversions credited to this dimension value."},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Credit-weighted attributed revenue for this dimension value."}},"required":["value","conversions","revenue"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/referrers":{"get":{"summary":"Conversions and revenue by referrer","description":"Returns attributed conversions and credit-weighted revenue per referrer.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Breakdown data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"value":{"type":"string","description":"The dimension value (e.g. 'Paid Social')."},"conversions":{"type":"number","description":"Attributed conversions credited to this dimension value."},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Credit-weighted attributed revenue for this dimension value."}},"required":["value","conversions","revenue"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/breakdown":{"get":{"summary":"Break conversions and revenue down by any set of dimensions","description":"One breakdown for any SET of dimensions, replacing the seven fixed\nsingle-dimension routes (`/channels`, `/pages`, `/countries`, `/devices`,\n`/browsers`, `/os`, `/referrers`). A single-dimension request returns the\nsame numbers as the fixed route it replaces.\n\n`data` is keyed by the dimension token you asked for, so `?dimensions=channel,country`\nreturns `{ \"channel\": [...], \"country\": [...] }`. Every requested dimension gets a\nkey even when it has no rows — an empty array means \"asked, and there was nothing\".\n\n`limit` applies PER DIMENSION: `?dimensions=channel,country&limit=5` returns up to five\nchannels and up to five countries, each ranked by attributed revenue.\n\nDimensions: `browser`, `campaign`, `channel`, `city`, `country`, `device`, `entry_page`, `medium`, `os`, `page`, `referrer`, `region`, `source`. `page` is an alias for\n`entry_page` (it is the LANDING page — Atribu does not measure pageviews).\n\nFilter-only: `goal`. `goal` scopes a\nbreakdown via `filter[goal]=is:payment_received` but is not itself a pivot —\nthe engine groups by attributed-touch dimensions, and a conversion type is not\none. Passing it in `dimensions` is a 400 rather than an empty bucket.\n\nCounts every revenue type, not just cash — the same choice the fixed routes make.\n\n`filter[<dimension>]=<operator>:<value>` (#625), e.g. `filter[channel]=is:Paid Social` — the\nsame parser `/campaigns`, `/overview` and `/quality/attribution` accept. Operators: `is`,\n`is_not`, `contains`, `not_contains`.\n\nScope: `analytics:read`.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"2026-08-01"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-08-20"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string","description":"Comma-separated dimension tokens. Required — there is no default set, because a default would quietly answer a question you did not ask.","example":"channel,country,campaign"},"required":true,"description":"Comma-separated dimension tokens. Required — there is no default set, because a default would quietly answer a question you did not ask.","name":"dimensions","in":"query"},{"schema":{"type":"string","description":"Attribution model. Defaults to `last_touch`.","example":"last_touch"},"required":false,"description":"Attribution model. Defaults to `last_touch`.","name":"model","in":"query"},{"schema":{"type":"string","description":"Rows per dimension. Default 10, capped at 100.","example":"10"},"required":false,"description":"Rows per dimension. Default 10, capped at 100.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated (#625).","example":"is:Paid Social"},"required":false,"description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated (#625).","name":"filter[channel]","in":"query"},{"schema":{"type":"string","description":"Filter by referrer: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated (#625)."},"required":false,"description":"Filter by referrer: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated (#625).","name":"filter[referrer]","in":"query"},{"schema":{"type":"string","description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated (#625)."},"required":false,"description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated (#625).","name":"filter[campaign]","in":"query"},{"schema":{"type":"string","description":"Filter by entry_page: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated (#625)."},"required":false,"description":"Filter by entry_page: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated (#625).","name":"filter[entry_page]","in":"query"},{"schema":{"type":"string","description":"Filter by goal: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated (#625)."},"required":false,"description":"Filter by goal: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated (#625).","name":"filter[goal]","in":"query"}],"responses":{"200":{"description":"The pivot, keyed by requested dimension","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","additionalProperties":{"type":"array","items":{"type":"object","properties":{"value":{"type":"string","description":"The dimension value (e.g. 'Paid Social')."},"conversions":{"type":"number","description":"Attributed conversions credited to this dimension value."},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Credit-weighted attributed revenue for this dimension value."}},"required":["value","conversions","revenue"]}},"description":"One entry per requested dimension token, each a list of that dimension's values ranked by attributed revenue."},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/campaigns":{"get":{"summary":"Get campaign performance","description":"TWO response shapes on one path, picked by whether `level` is present (#627) —\n\n- no `level` (the default) — `data` is `CampaignDTO[]`: a ranked top-N (honours `limit`).\n- `level=campaign|ad_set|ad` — `data` is `AdsRankingDTO[]`: every entity at that level, no limit, `filter[…]`-aware. Its rows publish `platform_id` (the provider's own id) alongside `entity_id` (the internal uuid) — use `platform_id` for a deep link back into the ads surface.\n\n**Filters** apply only when `level` is set — the default top-N shape ignores them. `filter[<dimension>]=<operator>:<value>`, e.g. `filter[channel]=is:Paid Social` — the same parser `/breakdown` and `/drill` accept. Operators: `is`, `is_not`, `contains`, `not_contains`. A malformed entry is silently skipped rather than rejected; a dimension the engine cannot scope spend by returns an empty result set rather than 400ing.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":false,"name":"model","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string"},"required":false,"name":"level","in":"query"},{"schema":{"type":"string","description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only applied on the `?level=` (ads-ranking) shape — the default top-N shape ignores filters. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated.","example":"is:Paid Social"},"required":false,"description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only applied on the `?level=` (ads-ranking) shape — the default top-N shape ignores filters. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated.","name":"filter[channel]","in":"query"},{"schema":{"type":"string","description":"Filter by referrer: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only applied on the `?level=` (ads-ranking) shape — the default top-N shape ignores filters. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated."},"required":false,"description":"Filter by referrer: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only applied on the `?level=` (ads-ranking) shape — the default top-N shape ignores filters. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated.","name":"filter[referrer]","in":"query"},{"schema":{"type":"string","description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only applied on the `?level=` (ads-ranking) shape — the default top-N shape ignores filters. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated."},"required":false,"description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only applied on the `?level=` (ads-ranking) shape — the default top-N shape ignores filters. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated.","name":"filter[campaign]","in":"query"},{"schema":{"type":"string","description":"Filter by entry_page: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only applied on the `?level=` (ads-ranking) shape — the default top-N shape ignores filters. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated."},"required":false,"description":"Filter by entry_page: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only applied on the `?level=` (ads-ranking) shape — the default top-N shape ignores filters. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated.","name":"filter[entry_page]","in":"query"},{"schema":{"type":"string","description":"Filter by goal: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only applied on the `?level=` (ads-ranking) shape — the default top-N shape ignores filters. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated."},"required":false,"description":"Filter by goal: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only applied on the `?level=` (ads-ranking) shape — the default top-N shape ignores filters. A malformed entry (missing colon, empty value) is silently skipped rather than rejected, and the dimension is not otherwise validated.","name":"filter[goal]","in":"query"}],"responses":{"200":{"description":"`CampaignDTO[]` without `level`; `AdsRankingDTO[]` with it — see the operation description.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"anyOf":[{"type":"array","items":{"type":"object","properties":{"campaign_id":{"type":"string"},"campaign_name":{"type":"string"},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"outcome_count":{"type":"number"},"outcome_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"roas":{"type":"number"},"campaign_external_id":{"type":"string"},"direct_cash_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"inherited_cash_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"direct_credited_conversions":{"type":"number"},"inherited_credited_conversions":{"type":"number"}},"required":["campaign_id","campaign_name","spend","outcome_count","outcome_value","roas"]}},{"type":"array","items":{"type":"object","properties":{"entity_id":{"type":"string"},"platform_id":{"type":"string","description":"The provider's own id (a Meta/Google campaign, ad-set or ad id) — not `entity_id`, which is the internal `ad_entity_dim` uuid. Send this back as the ads-surface deep-link selection."},"entity_name":{"type":"string"},"entity_level":{"type":"string"},"parent_name":{"type":"string"},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"impressions":{"type":"number"},"clicks":{"type":"number"},"reach":{"type":"number"},"ctr":{"type":"number"},"avg_cpm":{"type":"number"},"avg_cpc":{"type":"number"},"cac":{"type":"number"},"roas":{"type":"number"},"outcome_count":{"type":"number"},"outcome_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"status":{"type":"string"},"objective":{"type":"string"},"daily_budget":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"campaign_external_id":{"type":["string","null"],"description":"The campaign this entity belongs to. Null on a campaign row."},"ad_set_external_id":{"type":["string","null"],"description":"The ad set this entity belongs to. Null on a campaign or ad-set row."},"creative_thumbnail_url":{"type":["string","null"]},"video_id":{"type":["string","null"],"description":"The provider's video id when the creative is a video, else null."},"messaging_conversations_started_7d":{"type":"number","description":"Conversations started, 7-day window. The headline outcome of a click-to-message campaign, whose `outcome_value` is legitimately 0."},"cost_per_conversation_started":{"type":"number"},"depth_3_rate":{"type":"number","description":"Share of conversations that reached three user messages."},"outcome_count_first_payment":{"type":"integer","description":"The first-payment-only subset of `outcome_count`."},"outcome_value_first_payment":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"The first-payment-only subset of `outcome_value`. `outcome_value` / `roas` are not filtered by touch type, so a renewal credited via the `inherited` tier (the acquiring ad, not a fresh click) sits inside them like a first payment — use this pair to separate acquisition revenue from recurring credit."},"roas_first_payment":{"type":"number","description":"`outcome_value_first_payment` ÷ spend."},"cac_first_payment":{"type":"number"}},"required":["entity_id","platform_id","entity_name","entity_level","parent_name","spend","impressions","clicks","reach","ctr","avg_cpm","avg_cpc","cac","roas","outcome_count","outcome_value","status","objective","daily_budget","campaign_external_id","ad_set_external_id","creative_thumbnail_url","video_id","messaging_conversations_started_7d","cost_per_conversation_started","depth_3_rate","outcome_count_first_payment","outcome_value_first_payment","roas_first_payment","cac_first_payment"]}}]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"example":{"data":[{"campaign_id":"120211000000000001","campaign_name":"Prospecting — Broad LATAM","spend":"845200.00","outcome_count":61,"outcome_value":"3120000.00","roas":3.69}],"meta":{"date_from":"2026-08-01","date_to":"2026-08-31","profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/campaigns/{id}/conversions":{"get":{"summary":"Get a campaign's attributed conversions (drill-down)","description":"The conversions attributed to one campaign under the given model — the drill-down behind the campaign rollup at GET /api/v1/campaigns. `{id}` is the campaign's EXTERNAL (platform) id, the same value GET /api/v1/campaigns returns as `campaign_external_id` on each row — not `campaign_id` on that same resource, which is an internal identifier. Each row carries the credited share (credited_weight/credited_value), the touch's position within the full path (touch_count/path_length), and whether the credit is `is_inherited` (bridged lead→cash, never counted in ROAS). `customer_name`/`customer_email` are null unless the key also holds `customers:read`; every other field is visible under `campaigns:read` alone. The campaign's unattributed bucket has no external id and is not drillable — it returns an empty list.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The campaign's platform/external id."},"required":true,"description":"The campaign's platform/external id.","name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":false,"name":"model","in":"query"}],"responses":{"200":{"description":"Campaign conversion drill","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignConversionsResponse"}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/campaigns/trend":{"get":{"summary":"Get daily campaign trend","description":"Returns daily spend, impressions, and clicks for specific campaigns/ad sets/ads.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":true,"name":"entity_ids","in":"query"},{"schema":{"type":"string"},"required":false,"name":"level","in":"query"}],"responses":{"200":{"description":"Trend data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"entity_id":{"type":"string"},"date":{"type":"string"},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"impressions":{"type":"number"},"clicks":{"type":"number"}},"required":["entity_id","date","spend","impressions","clicks"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/top-performers":{"get":{"summary":"Get the profile's top-performing ads (cohort-normalized creative scoring)","description":"Best ads scored against *comparable* creative (a cohort = channel × format × objective × audience warmth × geo × placement): cohort-normalized, empirical-Bayes-smoothed (small-sample ads shrink toward the cohort mean), and maturity-staged. Each ad carries THREE distinct measures — present them separately, never merge: `composite_score` (0–100, a transparent rule blend), `top_performer_likelihood` (0–1, a probability — a *likelihood, not a guarantee*; the calibrated ML-ranker output when `score_source='model'` with `model_version` set, else a monotone function of `composite_score`; never ROAS), and `attributed_revenue`/`roas` (real cash attribution, present when `truth_grade='attributed'`). `maturity_stage` (cold→early→mature→calibrated) says how settled the score is; `reason_codes` explain which funnel layer is strong/weak with a sample-confidence band. Scores refresh on a daily cadence. `window` is a rolling lookback (`7d`/`14d`/`28d`/`lifetime`), not a custom date range. The workspace-wide cross-profile view is exposed via the Atribu MCP server's `top_workspace_performers` tool.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"]},"required":false,"name":"window","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string"},"required":false,"name":"min_spend","in":"query"},{"schema":{"type":"string"},"required":false,"name":"objectives","in":"query"},{"schema":{"type":"string"},"required":false,"name":"truth_grades","in":"query"},{"schema":{"type":"string"},"required":false,"name":"formats","in":"query"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"has_video","in":"query"}],"responses":{"200":{"description":"Top performers","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"ad_external_id":{"type":"string"},"ad_name":{"type":["string","null"]},"campaign_name":{"type":["string","null"]},"ad_set_name":{"type":["string","null"]},"creative_thumbnail_url":{"type":["string","null"]},"video_id":{"type":["string","null"]},"score_window":{"type":"string"},"composite_score":{"type":["number","null"]},"top_performer_likelihood":{"type":["number","null"]},"model_version":{"type":["number","null"]},"score_source":{"type":"string","enum":["model","rules"]},"maturity_stage":{"type":"string"},"truth_grade":{"type":"string"},"primary_outcome_kind":{"type":"string"},"cohort_key":{"type":["string","null"]},"cohort_objective":{"type":["string","null"]},"cohort_format":{"type":["string","null"]},"cohort_audience_warmth":{"type":["string","null"]},"cohort_channel":{"type":["string","null"]},"cohort_geo":{"type":["string","null"]},"cohort_placement":{"type":["string","null"]},"cohort_n_ads":{"type":"number"},"hook_type":{"type":["string","null"]},"creative_format":{"type":["string","null"]},"cta_type":{"type":["string","null"]},"primary_angle":{"type":["string","null"]},"offers":{"type":"array","items":{"type":"string"}},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"impressions":{"type":"number"},"ctr":{"type":"number"},"cpc":{"type":["number","null"]},"attributed_cash_outcomes":{"type":"integer"},"attributed_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"roas":{"type":["number","null"]},"cac":{"type":["number","null"]},"attributed_outcomes_total":{"type":"number"},"attributed_pipeline_outcomes":{"type":"number"},"attributed_pipeline_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"meta_reported_conversions":{"type":"number"},"meta_reported_conversion_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"cost_per_conversation":{"type":["number","null"]},"messaging_conversations_started":{"type":"number"},"first_reply_rate":{"type":["number","null"]},"avg_engagement_score":{"type":["number","null"]},"reason_codes":{"type":"array","items":{"type":"object","properties":{"layer":{"type":"string"},"polarity":{"type":"string","enum":["strength","weakness","forecast","fatigue"]},"percentile":{"type":["number","null"]},"sample_confidence":{"type":"string","enum":["low","medium","high"]},"sample_n":{"type":"number","description":"Cohort ads the percentile was ranked against. A percentile without its sample is a rank presented as a fact; `sample_confidence` is the band derived from this number."},"code":{"type":"string"},"metric_value":{"type":["number","null"]}},"required":["layer","polarity","percentile","sample_confidence","sample_n"]}},"forecast_outcomes_7d":{"type":["number","null"]},"forecast_outcomes_7d_low":{"type":["number","null"]},"forecast_outcomes_7d_high":{"type":["number","null"]},"forecast_confidence":{"type":["string","null"],"enum":["low","medium","high",null]},"hazard_30d_pause":{"type":["number","null"]},"hazard_30d_degradation":{"type":["number","null"]},"fatigue_state":{"type":["string","null"],"enum":["active","paused","degraded",null]},"fatigue_risk_tier":{"type":["string","null"],"enum":["low","medium","high","critical",null]},"expected_lifespan_days":{"type":["number","null"]},"identity":{"type":"object","properties":{"provider":{"type":"string","example":"meta"},"ad_entity_id":{"type":["string","null"]},"campaign_external_id":{"type":["string","null"]},"ad_set_external_id":{"type":["string","null"]},"creative_body":{"type":["string","null"]},"creative_title":{"type":["string","null"]},"day_from":{"type":["string","null"],"description":"First day of DELIVERY inside the window — not the window's nominal start. An ad that ran six days of a 28d window says so here; dividing by 28 instead of by `metrics.delivery.days_active` is wrong about every per-day number drawn from it."},"day_to":{"type":["string","null"]},"attribution_model_used":{"type":["string","null"],"description":"The model the `attributed_*` legs were credited under. Rows scored under different models are not comparable."}},"required":["provider","ad_entity_id","campaign_external_id","ad_set_external_id","creative_body","creative_title","day_from","day_to","attribution_model_used"]},"metrics":{"type":"object","properties":{"delivery":{"type":"object","properties":{"reach":{"type":"number"},"frequency":{"type":"number"},"clicks":{"type":"number"},"days_active":{"type":"number","description":"Days in the window with non-zero delivery — the denominator for any per-day rate."},"spend_reporting":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Spend in the PROFILE's reporting currency; the top-level `spend` is the ad account's own. They differ whenever an account bills in a currency the profile does not report in, and only this one is meaningful to sum across profiles."}},"required":["reach","frequency","clicks","days_active","spend_reporting"]},"video":{"type":"object","properties":{"video_plays":{"type":"number"},"video_thruplays":{"type":"number"},"thumb_stop_rate":{"type":["number","null"]},"hook_rate":{"type":["number","null"]},"retention_rate":{"type":["number","null"]},"completion_rate":{"type":["number","null"]},"hold_rate":{"type":["number","null"]},"video_avg_time_watched":{"type":["number","null"],"description":"Seconds, not a rate."}},"required":["video_plays","video_thruplays","thumb_stop_rate","hook_rate","retention_rate","completion_rate","hold_rate","video_avg_time_watched"],"description":"Every rate is NULL — never 0 — for an ad with no video. 'No plays to measure' and 'nobody held past three seconds' are opposite findings."},"messaging":{"type":"object","properties":{"messaging_first_reply":{"type":"number"},"messaging_conversations_replied":{"type":"number"},"messaging_user_depth_3":{"type":"number"},"messaging_total_connections":{"type":"number","description":"Distinct people reached on the messaging surface — the rates' denominator."},"reply_rate":{"type":["number","null"]},"depth_3_rate":{"type":["number","null"]},"depth_5_rate":{"type":["number","null"]},"block_rate":{"type":["number","null"],"description":"Share of connections that blocked. The one rate here that is bad when it is high."}},"required":["messaging_first_reply","messaging_conversations_replied","messaging_user_depth_3","messaging_total_connections","reply_rate","depth_3_rate","depth_5_rate","block_rate"],"description":"Conversation DEPTH, which is the messaging funnel's point: a conversation that starts and dies is not an outcome."},"outcomes":{"type":"object","properties":{"outcome_count_first_payment":{"type":"integer"},"outcome_value_first_payment":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"The FIRST-PAYMENT subset of `attributed_revenue`, not an addition to it. `roas_first_payment` is the acquisition ROAS a subscription business budgets against."},"roas_first_payment":{"type":["number","null"]},"cac_first_payment":{"type":["number","null"]},"high_quality_touches":{"type":"number"},"low_quality_touches":{"type":"number"}},"required":["outcome_count_first_payment","outcome_value_first_payment","roas_first_payment","cac_first_payment","high_quality_touches","low_quality_touches"]}},"required":["delivery","video","messaging","outcomes"]},"score":{"type":"object","properties":{"creative_fit_score":{"type":["number","null"]},"top_performer_likelihood_calibrated":{"type":["number","null"],"description":"The ML ranker's CALIBRATED probability — non-null only when a model actually scored this row (`score_source = 'model'`). The top-level `top_performer_likelihood` falls back to a monotone squash of `composite_score` when it did not, so this is how a consumer tells 'a model said 0.71' from 'the rule blend implies 0.71'."},"pct_delivery":{"type":["number","null"]},"pct_attention":{"type":["number","null"]},"pct_retention":{"type":["number","null"]},"pct_click_intent":{"type":["number","null"]},"pct_postclick_messaging":{"type":["number","null"]},"pct_attributed_revenue":{"type":["number","null"]},"pct_platform_diagnostics":{"type":["number","null"]},"smoothed_hook_rate":{"type":["number","null"]},"smoothed_hold_rate":{"type":["number","null"]},"smoothed_retention_rate":{"type":["number","null"]},"smoothed_completion_rate":{"type":["number","null"]},"smoothed_ctr":{"type":["number","null"]},"smoothed_first_reply_rate":{"type":["number","null"]},"smoothed_reply_rate":{"type":["number","null"]},"smoothed_depth_3_rate":{"type":["number","null"]},"smoothed_cost_per_conversation":{"type":["number","null"]},"smoothed_roas":{"type":["number","null"]}},"required":["creative_fit_score","top_performer_likelihood_calibrated","pct_delivery","pct_attention","pct_retention","pct_click_intent","pct_postclick_messaging","pct_attributed_revenue","pct_platform_diagnostics","smoothed_hook_rate","smoothed_hold_rate","smoothed_retention_rate","smoothed_completion_rate","smoothed_ctr","smoothed_first_reply_rate","smoothed_reply_rate","smoothed_depth_3_rate","smoothed_cost_per_conversation","smoothed_roas"]},"creative":{"type":"object","properties":{"camera_shot":{"type":["string","null"]},"claim_style":{"type":["string","null"]},"pain_points_addressed":{"type":"array","items":{"type":"string"}},"audience_targeted":{"type":"array","items":{"type":"string"}},"text_density":{"type":["string","null"]},"language_detected":{"type":["string","null"]},"has_video":{"type":"boolean"},"has_vision_analysis":{"type":"boolean","description":"The honesty flag on this whole group. False means the fields were derived from COPY alone, so an absent `camera_shot` is 'not looked at', not 'no camera movement'."},"creative_signature":{"type":["string","null"],"description":"Stable hash of the creative's structural features. Equal signatures mean the same creative — how a duplicate is spotted across profiles."}},"required":["camera_shot","claim_style","pain_points_addressed","audience_targeted","text_density","language_detected","has_video","has_vision_analysis","creative_signature"]},"forecast":{"type":"object","properties":{"forecast_impressions_7d":{"type":["number","null"]},"forecast_impressions_7d_low":{"type":["number","null"]},"forecast_impressions_7d_high":{"type":["number","null"]},"forecast_cost_per_outcome_7d":{"type":["number","null"],"description":"Projected cost per outcome over the next 7 days. A cost-per-unit RATIO (ADR 0018 §3) like `cost_per_conversation`, deliberately NOT monetary: it reconciles against no ledger, and splitting a metric from its own forecast across two wire types is how one quantity ends up with two shapes."}},"required":["forecast_impressions_7d","forecast_impressions_7d_low","forecast_impressions_7d_high","forecast_cost_per_outcome_7d"]},"internals":{"type":"object","properties":{"composite_score_breakdown":{}},"description":"UNSTABLE — NOT PART OF THE PUBLIC CONTRACT. Present only when `include=internals` is requested by a session credential (Atribu's own console); absent otherwise, and the parameter is ignored for any other credential. The shape belongs to the scoring engine and may change or disappear in any release without a version bump. Do not build against it."}},"required":["ad_external_id","ad_name","campaign_name","ad_set_name","creative_thumbnail_url","video_id","score_window","composite_score","top_performer_likelihood","model_version","score_source","maturity_stage","truth_grade","primary_outcome_kind","cohort_key","cohort_objective","cohort_format","cohort_audience_warmth","cohort_channel","cohort_geo","cohort_placement","cohort_n_ads","hook_type","creative_format","cta_type","primary_angle","offers","spend","impressions","ctr","cpc","attributed_cash_outcomes","attributed_revenue","roas","cac","attributed_outcomes_total","attributed_pipeline_outcomes","attributed_pipeline_value","meta_reported_conversions","meta_reported_conversion_value","cost_per_conversation","messaging_conversations_started","first_reply_rate","avg_engagement_score","reason_codes","forecast_outcomes_7d","forecast_outcomes_7d_low","forecast_outcomes_7d_high","forecast_confidence","hazard_30d_pause","hazard_30d_degradation","fatigue_state","fatigue_risk_tier","expected_lifespan_days","identity","metrics","score","creative","forecast"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/creative-patterns":{"get":{"summary":"Workspace creative pattern miner (Phase 2.7)","description":"Which structural creative patterns (archetype cluster, narrative arc, dominant-third role combo, role presence signature, social-proof duration bucket, hook×claim×cta) win MORE than the workspace baseline. One row per (pattern_dim, pattern_value) with sample_n, winner_n, win_rate, workspace_baseline_win_rate, lift_vs_workspace, Wilson 95% CI bounds, and the top-5 exemplar ads embodying the pattern. Filtered to the API key's profile via the workspace RPC's profile_ids filter. Pair with /top-performers for cash impact per ad.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"]},"required":false,"name":"window","in":"query"},{"schema":{"type":"string"},"required":false,"name":"min_cluster_size","in":"query"}],"responses":{"200":{"description":"Creative patterns","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"patterns":{"type":"array","items":{"type":"object","properties":{"pattern_dim":{"type":"string"},"pattern_value":{"type":["string","null"]},"pattern_label":{"type":["string","null"]},"pattern_description":{"type":["string","null"]},"pattern_winning_signal":{"type":["string","null"]},"sample_n":{"type":"number"},"winner_n":{"type":"number"},"win_rate":{"type":["number","null"]},"workspace_baseline_win_rate":{"type":["number","null"]},"lift_vs_workspace":{"type":["number","null"]},"confidence_interval_low":{"type":["number","null"]},"confidence_interval_high":{"type":["number","null"]},"exemplar_ad_external_ids":{"type":"array","items":{"type":"string"}}},"required":["pattern_dim","pattern_value","pattern_label","pattern_description","pattern_winning_signal","sample_n","winner_n","win_rate","workspace_baseline_win_rate","lift_vs_workspace","confidence_interval_low","confidence_interval_high","exemplar_ad_external_ids"]}},"score_window":{"type":"string"},"min_cluster_size":{"type":"number"}},"required":["patterns","score_window","min_cluster_size"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/archetypes":{"get":{"summary":"Workspace creative archetype summary (Phase 2.7)","description":"Workspace-level archetype headline: 'these are your creative clusters, named and ranked by lift.' One row per archetype_cluster with LLM-generated label/description/winning_signal, win-rate vs workspace baseline, Wilson 95% CI bounds, and top-5 exemplars. Archetypes are workspace-grain (cross-profile cohort percentiles); the response is NOT profile-scoped.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"]},"required":false,"name":"window","in":"query"}],"responses":{"200":{"description":"Archetype summary","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"archetypes":{"type":"array","items":{"type":"object","properties":{"archetype_cluster":{"type":"number"},"label":{"type":["string","null"]},"description":{"type":["string","null"]},"winning_signal":{"type":["string","null"]},"n_ads":{"type":"number"},"n_winners":{"type":"number"},"win_rate":{"type":["number","null"]},"workspace_baseline_win_rate":{"type":["number","null"]},"lift_vs_workspace":{"type":["number","null"]},"confidence_interval_low":{"type":["number","null"]},"confidence_interval_high":{"type":["number","null"]},"exemplar_ad_external_ids":{"type":"array","items":{"type":"string"}}},"required":["archetype_cluster","label","description","winning_signal","n_ads","n_winners","win_rate","workspace_baseline_win_rate","lift_vs_workspace","confidence_interval_low","confidence_interval_high","exemplar_ad_external_ids"]}},"score_window":{"type":"string"}},"required":["archetypes","score_window"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/ads/funnel-diagnosis":{"get":{"summary":"Per-ad funnel diagnosis (Phase 2.8)","description":"Per-ad funnel-layer percentile breakdown — delivery, attention, retention, click_intent, postclick_messaging, attributed_revenue, platform_diagnostics. Each layer carries the ad's percentile within its cohort, the smoothed metric, sample_n, weakest-layer flag, diagnostic codes (e.g. retention_below_p25_at_mature_stage), and a recommended_priority — one of: fix_offer_or_audience, fix_landing_or_offer, fix_hook, improve_creative_clarity, increase_spend, rework_offer_or_landing, maintain. Cold-maturity ads with spend < $50 return 'increase_spend' (the 'spend_below_learning_phase' code). When the ad has no per-layer percentiles yet but at least one diagnostic_code applies, the response contains a single layer='no_data' row carrying the codes + recommended_priority. Scope: campaigns:read.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"ad_external_id","in":"query"},{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"]},"required":false,"name":"window","in":"query"}],"responses":{"200":{"description":"Funnel diagnosis","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"ad_external_id":{"type":"string"},"score_window":{"type":"string"},"weakest_layer":{"type":["string","null"]},"recommended_priority":{"type":"string"},"layers":{"type":"array","items":{"type":"object","properties":{"layer":{"type":"string"},"percentile":{"type":["number","null"]},"smoothed_metric":{"type":["number","null"]},"sample_n":{"type":["number","null"]},"is_weakest_layer":{"type":"boolean"},"diagnostic_codes":{"type":"array","items":{"type":"string"}},"recommended_priority":{"type":"string"},"maturity_stage":{"type":["string","null"]},"truth_grade":{"type":["string","null"]}},"required":["layer","percentile","smoothed_metric","sample_n","is_weakest_layer","diagnostic_codes","recommended_priority","maturity_stage","truth_grade"]}}},"required":["ad_external_id","score_window","weakest_layer","recommended_priority","layers"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/ads/compare":{"get":{"summary":"Compare two ads by feature contributions (Phase 2.8)","description":"Returns the top-N features most responsible for the gap between two ads' rankings, sorted by |model_contribution_a − model_contribution_b|. Each row has the feature name, raw values for A and B, the numeric delta, and per-ad model contributions sourced from `model_feature_contributions`.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"ad_a","in":"query"},{"schema":{"type":"string"},"required":true,"name":"ad_b","in":"query"},{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"]},"required":false,"name":"window","in":"query"},{"schema":{"type":"string"},"required":false,"name":"top_n","in":"query"}],"responses":{"200":{"description":"Feature comparison","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"ad_external_id_a":{"type":"string"},"ad_external_id_b":{"type":"string"},"score_window":{"type":"string"},"features":{"type":"array","items":{"type":"object","properties":{"feature_name":{"type":"string"},"value_a":{"type":["string","null"]},"value_b":{"type":["string","null"]},"delta":{"type":["number","null"]},"model_contribution_a":{"type":["number","null"]},"model_contribution_b":{"type":["number","null"]},"contribution_delta_abs":{"type":["number","null"]}},"required":["feature_name","value_a","value_b","delta","model_contribution_a","model_contribution_b","contribution_delta_abs"]}}},"required":["ad_external_id_a","ad_external_id_b","score_window","features"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/ads/trajectory":{"get":{"summary":"Per-ad daily history trajectory (Phase 2.8)","description":"Daily snapshot rows for one ad from `creative_feature_store_history`. Each snapshot carries spend, smoothed funnel-layer percentiles, composite_score, maturity_stage, truth_grade, and delta_composite_vs_prev. Backs charts of how an ad's performance evolves over time. Default 60 days back, max 365.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"ad_external_id","in":"query"},{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"]},"required":false,"name":"window","in":"query"},{"schema":{"type":"string"},"required":false,"name":"days_back","in":"query"}],"responses":{"200":{"description":"Ad trajectory","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"ad_external_id":{"type":"string"},"score_window":{"type":"string"},"days_back":{"type":"number"},"points":{"type":"array","items":{"type":"object","properties":{"snapshot_date":{"type":"string"},"spend":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"impressions":{"type":["number","null"]},"clicks":{"type":["number","null"]},"ctr":{"type":["number","null"]},"smoothed_hook_rate":{"type":["number","null"]},"smoothed_retention_rate":{"type":["number","null"]},"smoothed_ctr":{"type":["number","null"]},"smoothed_roas":{"type":["number","null"]},"pct_delivery":{"type":["number","null"]},"pct_attention":{"type":["number","null"]},"pct_retention":{"type":["number","null"]},"pct_click_intent":{"type":["number","null"]},"pct_attributed_revenue":{"type":["number","null"]},"attributed_revenue":{"type":["string","null"],"format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"attributed_outcomes_total":{"type":["number","null"]},"messaging_conversations_started":{"type":["number","null"]},"refund_rate":{"type":["number","null"]},"composite_score":{"type":["number","null"]},"maturity_stage":{"type":["string","null"]},"truth_grade":{"type":["string","null"]},"primary_outcome_kind":{"type":["string","null"]},"delta_composite_vs_prev":{"type":["number","null"]}},"required":["snapshot_date","spend","impressions","clicks","ctr","smoothed_hook_rate","smoothed_retention_rate","smoothed_ctr","smoothed_roas","pct_delivery","pct_attention","pct_retention","pct_click_intent","pct_attributed_revenue","attributed_revenue","attributed_outcomes_total","messaging_conversations_started","refund_rate","composite_score","maturity_stage","truth_grade","primary_outcome_kind","delta_composite_vs_prev"]}}},"required":["ad_external_id","score_window","days_back","points"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/ads/fatigue-risk":{"get":{"summary":"Workspace fatigue risk ranking (Phase 4)","description":"Returns ads at predicted fatigue risk (high/critical tier only, fatigue_state='active') ranked by `hazard_30d_pause × spend` — the projected budget at risk if each ad pauses within 30 days. The Cox cause-specific 30-day pause probability is a *likelihood, not a guarantee*. Excludes already paused/degraded ads (those are observed states surfaced separately). Each row carries `fatigue_risk_tier`, `hazard_30d_pause`, `hazard_30d_degradation` (when the degradation cause has trained, else null), `expected_lifespan_days` (trapezoidal E[T] from the survival curve), and `risk_rank_score`. Models retrain weekly.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"],"description":"Default '28d'"},"required":false,"description":"Default '28d'","name":"score_window","in":"query"},{"schema":{"type":"string","description":"Top N ads (default 20, max 50)"},"required":false,"description":"Top N ads (default 20, max 50)","name":"top","in":"query"},{"schema":{"type":"string","description":"Minimum spend filter (default 100; pass 0 to include all)"},"required":false,"description":"Minimum spend filter (default 100; pass 0 to include all)","name":"min_spend","in":"query"}],"responses":{"200":{"description":"Workspace fatigue risk ranking","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"score_window":{"type":"string"},"top_n":{"type":"number"},"min_spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"ads":{"type":"array","items":{"type":"object","additionalProperties":{}}}},"required":["score_window","top_n","min_spend","ads"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/forecast-outlook":{"get":{"summary":"Workspace portfolio forecast outlook (Phase 4)","description":"Portfolio-grain forecast for the next 7 days. Returns one document with: projected total impressions + attributed outcomes (with 80% prediction-interval bands from split-conformal calibration — calibrated coverage bands, not Gaussian σ), projected average cost-per-outcome, the counts of `n_emerging_top_performers` (climbing ads with strong forecasts) and `n_at_risk_top_performers` (currently top-tier ads in high/critical fatigue), per-tier fatigue histograms, and `projected_budget_at_risk` (total spend across high+critical fatigue ads). Use as the first 'what's the week ahead' lookup.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"],"description":"Default '28d'"},"required":false,"description":"Default '28d'","name":"score_window","in":"query"}],"responses":{"200":{"description":"Portfolio outlook (single document)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","additionalProperties":{}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/ads/forecast":{"get":{"summary":"Per-ad forecast + fatigue + 14d trajectory (Phase 4 / F-3)","description":"Returns one creative_feature_store row for the (workspace, profile, ad_external_id) tuple plus the last 14 days of daily delivery and any lifecycle events. The `cfs` object carries the Phase-4 forecast cols (forecast_outcomes_7d/_low/_high, forecast_confidence) and the fatigue cols (hazard_30d_pause, hazard_30d_degradation, expected_lifespan_days, fatigue_state, fatigue_risk_tier). All Phase-4 cols are NULL when the prediction-pass cron hasn't scored the ad (cold-start, paused, ML_FORECAST_AND_FATIGUE_ENABLED off). Query-param convention (audit B-7).","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The ad's external id (Meta ad id / Google ads id)"},"required":true,"description":"The ad's external id (Meta ad id / Google ads id)","name":"ad_external_id","in":"query"},{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"],"description":"Default '28d'"},"required":false,"description":"Default '28d'","name":"score_window","in":"query"}],"responses":{"200":{"description":"Ad forecast detail","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"ad_external_id":{"type":"string"},"score_window":{"type":"string"},"cfs":{"type":["object","null"],"additionalProperties":{}},"trajectory_last_14d":{"type":"array","items":{"type":"object","properties":{"day":{"type":"string"},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"impressions":{"type":"number"},"clicks":{"type":"number"}},"required":["day","spend","impressions","clicks"]}},"lifecycle_history":{"type":"array","items":{"type":"object","properties":{"event_date":{"type":"string"},"event_type":{"type":"string"},"lifecycle_seq":{"type":"number"}},"required":["event_date","event_type","lifecycle_seq"]}}},"required":["ad_external_id","score_window","cfs","trajectory_last_14d","lifecycle_history"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/ads/forecast/scenarios":{"post":{"summary":"Per-ad what-if forecast scenarios (Phase 4 / F-3)","description":"Feeds modified spend / frequency overrides back through the active outcomes-head forecaster and returns the projected 7-day outcomes count per scenario, with 80% conformal PI bands. IMPORTANT: spend-scaling scenarios are a naive linear extrapolation, not a saturation model — the per-dollar outcome rate is held fixed, so outcomes scale in direct proportion to spend (marginal = average return). Such scenarios carry `projection_basis: 'linear_spend_extrapolation'` + `assumes_constant_returns: true`, and the top-level `basis_note` spells this out; do not present them as a guarantee that more budget yields proportional sales. The `out_of_range` flag goes true when a scenario's spend is > 2x or < 0.5x the ad's current trailing-7d spend — extrapolating beyond the training distribution; even less reliable. Up to 5 scenarios per call. Returns base=null + per-scenario `gated_reason` when the ad is cold-start / paused / has no active forecaster model.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ad_external_id":{"type":"string"},"score_window":{"type":"string","enum":["7d","14d","28d","lifetime"]},"scenarios":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"spend_7d_override":{"anyOf":[{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56"},{"type":"number"}]},"frequency_override":{"type":"number","minimum":0}},"required":["name"]},"minItems":1,"maxItems":5}},"required":["ad_external_id","scenarios"]}}}},"responses":{"200":{"description":"Per-scenario projection","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"ad_external_id":{"type":"string"},"score_window":{"type":["string","null"]},"cohort_variant":{"type":"string"},"base_trailing_7d_spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"assumes_constant_returns":{"type":"boolean"},"basis_note":{"type":"string"},"base":{"type":["object","null"],"properties":{"point":{"type":"number"},"low":{"type":"number"},"high":{"type":"number"},"confidence":{"type":"string","enum":["low","medium","high"]}},"required":["point","low","high","confidence"]},"scenarios":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"point":{"type":["number","null"]},"low":{"type":["number","null"]},"high":{"type":["number","null"]},"confidence":{"type":"string","enum":["low","medium","high"]},"delta_from_base":{"type":"number"},"out_of_range":{"type":"boolean"},"projection_basis":{"type":"string","enum":["model_response","linear_spend_extrapolation"]},"assumes_constant_returns":{"type":"boolean"},"gated_reason":{"type":"string"}},"required":["name","point","low","high","confidence","delta_from_base","out_of_range"]}}},"required":["ad_external_id","score_window","base","scenarios"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversions":{"get":{"summary":"Get conversion counts by type","description":"Returns counts of each conversion type (lead_created, payment_received, etc.).","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"}],"responses":{"200":{"description":"Conversion data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"event_type":{"type":"string"},"count":{"type":"number"}},"required":["event_type","count"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["conversions:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversions/timeseries":{"get":{"summary":"Get daily conversion counts","description":"Returns daily conversion counts broken down by event type.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"}],"responses":{"200":{"description":"Conversion timeseries","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string"},"event_type":{"type":"string"},"count":{"type":"number"}},"required":["date","event_type","count"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["conversions:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/revenue":{"get":{"summary":"Get daily revenue and spend","description":"Returns attributed revenue (cash only) and ad spend per day.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":false,"name":"model","in":"query"}],"responses":{"200":{"description":"Revenue data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string"},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"organic_direct_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["date","revenue","organic_direct_revenue","spend"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"example":{"data":[{"date":"2026-08-01","revenue":"1245000.00","organic_direct_revenue":"180000.00","spend":"312500.00"}],"meta":{"date_from":"2026-08-01","date_to":"2026-08-01","profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}}},"x-atribu-scopes":["conversions:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/revenue/cash":{"get":{"summary":"Get cash collected by source","description":"Returns total cash payments grouped by payment source (Stripe, MercadoPago) and currency.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"}],"responses":{"200":{"description":"Cash data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string"},"currency":{"type":"string"},"total_cash":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Sum of payments in the original currency (was `total` — renamed in #796)."},"count":{"type":"number"}},"required":["source","currency","total_cash","count"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["conversions:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/quality":{"get":{"summary":"Get attribution data quality","description":"Returns metrics on UTM tracking coverage and attribution quality.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"}],"responses":{"200":{"description":"Quality data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"total_events":{"type":"number"},"with_full_utms":{"type":"number"},"with_fbclid_only":{"type":"number"},"with_no_tracking":{"type":"number"},"coverage_percent":{"type":"number"}},"required":["total_events","with_full_utms","with_fbclid_only","with_no_tracking","coverage_percent"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/attribution-coverage":{"get":{"summary":"Get cash attribution coverage (honesty breakdown)","description":"Splits the profile's cash conversions into four mutually-exclusive honesty buckets — ad_attributed, dangling_ad, organic_direct, unattributed — by count and reporting value, plus a traceable percentage. An attribution-COVERAGE measure (how much cash traces to a specific ad), NOT ROAS or a likelihood.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":false,"name":"model","in":"query"}],"responses":{"200":{"description":"Coverage data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/CashAttributionCoverage"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/customers":{"get":{"summary":"Get customer conversions","description":"Paginated list of customers who achieved a goal. Requires `customers:read` scope. Returns PII (name, email).\n\n**Filters** are `filter[<dimension>]=<operator>:<value>`, e.g. `filter[channel]=is:Paid Social`. Dimensions: `channel`, `campaign`, `country`. Operators: `is`, `is_not`, `contains`, `not_contains`. Anything else is 400 `invalid_parameter` — a filter this endpoint cannot apply is refused, never silently dropped.\n\nA row is returned when the customer/visitor has **at least one touch** matching every filter (for `/customers`, a touch at or before the conversion; for `/visitors`, a touch inside the requested window). That is the same rule the rest of the attribution engine applies, so a filtered list reconciles with a filtered KPI. Note the consequence: the `channel` / `campaign_name` / `country` a row DISPLAYS are first-touch (customers) or last-touch (visitors) projections, so a returned row may show a different channel than the one filtered on.\n\n`campaign` compares against the campaign identifier carried on the touch — the value surfaced as `raw_campaign`, which for Meta is the campaign's platform id — not the resolved campaign name. `country` is the ISO-3166 alpha-2 code, the same value returned as `country`.\n\nFiltering is applied in the database before pagination, so a filtered page is a full page and `pagination.cursor` stays correct.","tags":["Customers"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":true,"name":"goal","in":"query"},{"schema":{"type":"string"},"required":false,"name":"search","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","description":"The opaque `pagination.cursor` from the previous page. Preferred over the `cursor_time`/`cursor_id` pair, which predates it."},"required":false,"description":"The opaque `pagination.cursor` from the previous page. Preferred over the `cursor_time`/`cursor_id` pair, which predates it.","name":"cursor","in":"query"},{"schema":{"type":"string"},"required":false,"name":"cursor_time","in":"query"},{"schema":{"type":"string"},"required":false,"name":"cursor_id","in":"query"},{"schema":{"type":"string","description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. An unusable operator or value is 400 `invalid_parameter`, never ignored. See the operation description for what the value is compared against."},"required":false,"description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. An unusable operator or value is 400 `invalid_parameter`, never ignored. See the operation description for what the value is compared against.","name":"filter[channel]","in":"query"},{"schema":{"type":"string","description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. An unusable operator or value is 400 `invalid_parameter`, never ignored. See the operation description for what the value is compared against."},"required":false,"description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. An unusable operator or value is 400 `invalid_parameter`, never ignored. See the operation description for what the value is compared against.","name":"filter[campaign]","in":"query"},{"schema":{"type":"string","description":"Filter by country: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. An unusable operator or value is 400 `invalid_parameter`, never ignored. See the operation description for what the value is compared against."},"required":false,"description":"Filter by country: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. An unusable operator or value is 400 `invalid_parameter`, never ignored. See the operation description for what the value is compared against.","name":"filter[country]","in":"query"}],"responses":{"200":{"description":"Customer list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"conversion_id":{"type":"string"},"customer_profile_id":{"type":["string","null"]},"anonymous_id":{"type":["string","null"],"description":"The visitor's device id when they are not identified. Pass whichever of this and `customer_profile_id` is non-null to `GET /api/v1/customers/{id}/journey`, which takes either."},"name":{"type":["string","null"]},"first_name":{"type":["string","null"]},"last_name":{"type":["string","null"]},"email":{"type":["string","null"]},"country":{"type":["string","null"]},"device":{"type":["string","null"]},"browser":{"type":["string","null"]},"os":{"type":["string","null"]},"channel":{"type":["string","null"],"description":"FIRST touch's channel — the acquisition. (`country` / `device` / `browser` / `os` are the LAST touch's: a device is a fact about the person now, a channel is a fact about how they arrived.)"},"source":{"type":["string","null"]},"placement":{"type":["string","null"],"description":"Normalized ad placement. Always null today — the durable touch layer carries no placement dimension."},"campaign_name":{"type":["string","null"]},"raw_source":{"type":["string","null"],"description":"The first touch's UTMs exactly as captured. `raw_campaign` is the campaign's PLATFORM ID, which is what a `filter[campaign]` predicate compares against; `campaign_name` is its resolved label."},"raw_medium":{"type":["string","null"]},"raw_campaign":{"type":["string","null"]},"revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"revenue_type":{"type":"string"},"first_touch_at":{"type":["string","null"]},"conversion_time":{"type":"string"},"time_to_complete_seconds":{"type":"number"},"touch_count":{"type":"number"},"touch_channels":{"type":"array","items":{"type":"string"}},"conversion_count":{"type":"number"},"total_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."}},"required":["conversion_id","customer_profile_id","anonymous_id","name","first_name","last_name","email","country","device","browser","os","channel","source","placement","campaign_name","raw_source","raw_medium","raw_campaign","revenue","revenue_type","first_touch_at","conversion_time","time_to_complete_seconds","touch_count","touch_channels","conversion_count","total_revenue"]}},"pagination":{"$ref":"#/components/schemas/Pagination"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","pagination","meta"]},"example":{"data":[{"conversion_id":"e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b","customer_profile_id":"b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e","anonymous_id":null,"name":"Camila Rojas","first_name":"Camila","last_name":"Rojas","email":"camila.rojas@example.com","country":"CL","device":"mobile","browser":"Chrome","os":"Android","channel":"Paid Social","source":"facebook","placement":null,"campaign_name":"Prospecting — Broad LATAM","raw_source":"fb","raw_medium":"cpc","raw_campaign":"120211000000000001","revenue":"89990.00","revenue_type":"cash","first_touch_at":"2026-08-20T14:00:00.000Z","conversion_time":"2026-08-21T09:30:00.000Z","time_to_complete_seconds":70200,"touch_count":3,"touch_channels":["Paid Social","Direct"],"conversion_count":1,"total_revenue":"89990.00"}],"pagination":{"has_next":false},"meta":{"date_from":"2026-08-01","date_to":"2026-08-31","profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"400":{"description":"Missing goal, a bad date range, or a filter this endpoint cannot apply","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["customers:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Batch-import customer identities from your own records","description":"Upserts up to 1,000 customer identities directly into the identity graph — the write sibling of GET /api/v1/customers. No event is recorded; this is for a PMS/CRM export or a CSV, not a sale.\n\nEach row needs AT LEAST ONE of `phone`, `email` or `national_id`. A row with only `external_id` is REJECTED, never silently dropped and never used to mint an orphan profile.\n\nThe response reports every row's outcome — `created`, `matched` (resolved to an existing profile), or `rejected` (with `reason`) — in request order, so a bad row never hides the rows around it.\n\nRequires the `attribution:write` scope.","tags":["Write"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomerImportBody"}}}},"responses":{"200":{"description":"Every row processed — check each row's own `status`.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/CustomerImportResponse"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"400":{"description":"No rows, more than 1,000 rows, or a malformed body.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/visitors":{"get":{"summary":"Get visitor list","description":"Paginated list of all visitors (identified and anonymous). Requires `visitors:read` scope.\n\n**Filters** are `filter[<dimension>]=<operator>:<value>`, e.g. `filter[channel]=is:Paid Social`. Dimensions: `channel`, `campaign`, `country`. Operators: `is`, `is_not`, `contains`, `not_contains`. Anything else is 400 `invalid_parameter` — a filter this endpoint cannot apply is refused, never silently dropped.\n\nA row is returned when the customer/visitor has **at least one touch** matching every filter (for `/customers`, a touch at or before the conversion; for `/visitors`, a touch inside the requested window). That is the same rule the rest of the attribution engine applies, so a filtered list reconciles with a filtered KPI. Note the consequence: the `channel` / `campaign_name` / `country` a row DISPLAYS are first-touch (customers) or last-touch (visitors) projections, so a returned row may show a different channel than the one filtered on.\n\n`campaign` compares against the campaign identifier carried on the touch — the value surfaced as `raw_campaign`, which for Meta is the campaign's platform id — not the resolved campaign name. `country` is the ISO-3166 alpha-2 code, the same value returned as `country`.\n\nFiltering is applied in the database before pagination, so a filtered page is a full page and `pagination.cursor` stays correct.","tags":["Visitors"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":false,"name":"search","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","description":"The opaque `pagination.cursor` from the previous page. Preferred over the `cursor_time`/`cursor_id` pair, which predates it."},"required":false,"description":"The opaque `pagination.cursor` from the previous page. Preferred over the `cursor_time`/`cursor_id` pair, which predates it.","name":"cursor","in":"query"},{"schema":{"type":"string"},"required":false,"name":"cursor_time","in":"query"},{"schema":{"type":"string"},"required":false,"name":"cursor_id","in":"query"},{"schema":{"type":"string","description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. An unusable operator or value is 400 `invalid_parameter`, never ignored. See the operation description for what the value is compared against."},"required":false,"description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. An unusable operator or value is 400 `invalid_parameter`, never ignored. See the operation description for what the value is compared against.","name":"filter[channel]","in":"query"},{"schema":{"type":"string","description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. An unusable operator or value is 400 `invalid_parameter`, never ignored. See the operation description for what the value is compared against."},"required":false,"description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. An unusable operator or value is 400 `invalid_parameter`, never ignored. See the operation description for what the value is compared against.","name":"filter[campaign]","in":"query"},{"schema":{"type":"string","description":"Filter by country: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. An unusable operator or value is 400 `invalid_parameter`, never ignored. See the operation description for what the value is compared against."},"required":false,"description":"Filter by country: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. An unusable operator or value is 400 `invalid_parameter`, never ignored. See the operation description for what the value is compared against.","name":"filter[country]","in":"query"}],"responses":{"200":{"description":"Visitor list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"visitor_id":{"type":"string"},"customer_profile_id":{"type":["string","null"]},"name":{"type":["string","null"]},"email":{"type":["string","null"]},"country":{"type":["string","null"]},"device":{"type":["string","null"]},"browser":{"type":["string","null"]},"os":{"type":["string","null"]},"channel":{"type":["string","null"]},"source":{"type":["string","null"]},"total_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"last_seen_at":{"type":"string"},"session_count":{"type":"number"},"total_pageviews":{"type":["integer","null"],"deprecated":true,"description":"Always null: pageview capture was retired (ADR 0006). Retained for wire compatibility; removed in the next major."},"touch_channels":{"type":"array","items":{"type":"string"}}},"required":["visitor_id","customer_profile_id","name","email","country","device","browser","os","channel","source","total_revenue","last_seen_at","session_count","total_pageviews","touch_channels"]}},"pagination":{"$ref":"#/components/schemas/Pagination"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","pagination","meta"]},"example":{"data":[{"visitor_id":"d0e1f2a3-b4c5-4d6e-7f8a-9b0c1d2e3f4a","customer_profile_id":null,"name":null,"email":null,"country":"CL","device":"mobile","browser":"Chrome","os":"Android","channel":"Paid Social","source":"facebook","total_revenue":"0","last_seen_at":"2026-08-28T02:00:00.000Z","session_count":3,"total_pageviews":0}],"pagination":{"has_next":false},"meta":{"date_from":"2026-08-01","date_to":"2026-08-31","profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"400":{"description":"A bad date range, or a filter this endpoint cannot apply","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["visitors:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/keywords":{"get":{"summary":"Get search keywords","description":"Returns Google Search Console keyword data (impressions, clicks, CTR, position).","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Keyword data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"keyword":{"type":"string"},"impressions":{"type":"number"},"clicks":{"type":"number"},"ctr":{"type":"number"},"avg_position":{"type":"number"}},"required":["keyword","impressions","clicks","ctr","avg_position"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/events":{"post":{"summary":"Ingest a server-side outcome event (e.g. a closed sale)","description":"Records an outcome event — the path an ERP or backend posts a closed sale through, so Atribu can join the sale back to the ad click that produced it.\n\n**Event name.** There is no `sale_closed` type. Send `payment_received` for a sale where money changed hands (revenue type `cash`, the only kind counted as revenue for ROAS) or `closed_won` for a won-but-unpaid deal (`pipeline`, deliberately excluded from ROAS). Any other name is accepted and stored, but produces no conversion unless a goal lists it in `source_event_names` — the response's `conversion.matched` tells you which happened.\n\n**Idempotency.** Send the sale's deal/order id as the `Idempotency-Key` header or `idempotency_key` field; retries collapse onto one outcome event. It is REQUIRED when `properties.value` is set.\n\n**Attributability.** Include whatever identity you have: `anonymous_id` (strongest — the tracker's visitor id from your checkout), `user_traits.email`/`phone`, `click_ids.fbclid`/`gclid`, `session_id`, `utm`. Attribution runs asynchronously; 202 means the event was recorded, not that credit has been assigned yet.\n\nRequires the `events:write` scope.","tags":["Write"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngestEventBody"}}}},"responses":{"202":{"description":"Event recorded. Conversion extraction and attribution run asynchronously.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IngestEventAccepted"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"400":{"description":"Invalid body — including a value-bearing event sent without an idempotency key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["events:write"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"header","x-atribu-scope-grain":"profile"}},"/api/v1/payments/webpay":{"post":{"summary":"Report a Webpay (Transbank) payment","description":"Records a completed Webpay Plus transaction as a cash outcome event, with the ad click that produced it.\n\n**Why this exists.** Webpay has no `metadata` field and no webhook, so the Stripe/MercadoPago passthrough has nowhere to live. The only merchant-supplied values Transbank echoes back on `commit` are `buy_order` (26 chars) and `session_id` (61) — and the `atb1.` attribution token is 80–904 characters, so it cannot ride either.\n\n**Two steps.** (1) When you call `Transaction.create()`, send `session_id: atribuTracker.getAttributionRef() || yourOwnSessionId` — a 48-character packed reference to the visitor. (2) After `Transaction.commit()`, POST the response here verbatim. Atribu maps `amount`/`transaction_date`/`buy_order` and decodes the visitor from `session_id`.\n\n**Idempotency is automatic**: the event is filed under `<buy_order>_<authorization_code>`, the same key the webpay.cl report importer uses, so retries — and a later monthly PDF upload of the same payment — collapse onto one conversion.\n\nOnly `status: AUTHORIZED` with `response_code: 0` becomes revenue; anything else comes back as `ignored`. Requires the `events:write` scope.","tags":["Write"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"commit":{"type":"object","properties":{"buy_order":{"type":"string","maxLength":26,"description":"Transbank's `buy_order`, echoed back verbatim on commit. With `authorization_code` it forms the idempotency key — and it is the SAME key the webpay.cl report importer builds from the report's OC column, so a monthly PDF upload lands on this row instead of double-counting the sale.","example":"orden-8891"},"session_id":{"type":["string","null"],"maxLength":61,"description":"Transbank's `session_id`, echoed back verbatim. This is the attribution carrier: put `atribuTracker.getAttributionRef()` here when you call `Transaction.create()` and the sale ties to the exact ad click with no email match. Your own value passes through untouched when it is not an Atribu ref.","example":"atbwASNFZ4mrze8BI0VniavN7w_ty6mHZUMhD-3LqYdlQyEA"},"amount":{"anyOf":[{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56"},{"type":"number"}],"description":"The authorized amount. Integer for CLP; up to 2 decimals on the USD products. Send Transbank's own number, or an exact decimal string."},"status":{"type":"string","description":"Webpay transaction status. Only `AUTHORIZED` becomes a cash conversion; anything else is reported back as `ignored` and stored nowhere.","example":"AUTHORIZED"},"authorization_code":{"type":["string","null"],"maxLength":6,"description":"Transbank's authorization code. Required on an AUTHORIZED transaction — it is half the idempotency key."},"transaction_date":{"type":["string","null"],"description":"ISO-8601 authorization timestamp. Becomes the outcome event's `event_time`, which is what the attribution window is measured against. Defaults to now if omitted.","example":"2026-09-02T16:41:21.063Z"},"accounting_date":{"type":["string","null"]},"payment_type_code":{"type":["string","null"],"description":"VD/VN/VC/SI/S2/NC/VP."},"response_code":{"type":["number","null"],"description":"0 is approved. Any other value is a decline and is ignored even when `status` reads AUTHORIZED."},"installments_number":{"type":["integer","null"]},"installments_amount":{"anyOf":[{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56"},{"type":"number"},{"type":"null"}],"description":"Monto de las cuotas."},"balance":{"anyOf":[{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56"},{"type":"number"},{"type":"null"}],"description":"Monto restante para un detalle anulado."},"vci":{"type":["string","null"],"description":"Stored, never validated — Transbank's own reference tells merchants not to gate on it."},"card_detail":{"type":["object","null"],"properties":{"card_number":{"type":["string","null"]}}}},"required":["buy_order","amount","status"],"description":"The `Transaction.commit` response from Transbank, forwarded verbatim. `Transaction.status` returns the identical field set and is equally acceptable."},"currency":{"type":"string","minLength":3,"maxLength":3,"description":"ISO-4217 override. Defaults to CLP — Webpay Plus settles in Chilean pesos."},"attribution_token":{"type":"string","description":"A full `atb1.` token from `getAttributionToken()`, if your backend kept one in its own session across the redirect. It carries the UTMs and click ids as well as the identity, so it wins over the ref in `session_id`. Optional — the ref alone is enough."},"customer":{"allOf":[{"$ref":"#/components/schemas/IngestEventUserTraits"},{"description":"Buyer identity, if you have it. Webpay returns none — it hands back only the last 4 card digits."}]}},"required":["commit"],"description":"A completed Webpay Plus transaction. Post the commit response exactly as Transbank returned it; Atribu does the field mapping.","example":{"commit":{"vci":"TSY","amount":189900,"status":"AUTHORIZED","buy_order":"orden-8891","session_id":"atbwASNFZ4mrze8BI0VniavN7w_ty6mHZUMhD-3LqYdlQyEA","card_detail":{"card_number":"6623"},"accounting_date":"0902","transaction_date":"2026-09-02T16:41:21.063Z","authorization_code":"1213","payment_type_code":"VN","response_code":0,"installments_number":0},"customer":{"email":"compradora@example.cl"}}}}}},"responses":{"202":{"description":"Commit processed. `status: accepted` recorded a cash event; `status: ignored` means the transaction was not an authorized sale.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WebpayIngestResult"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"400":{"description":"Invalid body — including an AUTHORIZED transaction with no authorization code.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["events:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/goals":{"get":{"summary":"List conversion goals","description":"Returns all conversion definitions for this profile.","tags":["Write"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"Goal list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"display_name":{"type":"string"},"conversion_key":{"type":"string"},"source_event_names":{"type":"array","items":{"type":"string"}},"revenue_type":{"type":"string"},"attribution_eligible":{"type":"boolean"}},"required":["id","display_name","conversion_key","source_event_names","revenue_type","attribution_eligible"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"example":{"data":[{"id":"b4c5d6e7-f8a9-4b0c-1d2e-3f4a5b6c7d8e","display_name":"Venta cerrada","conversion_key":"sale_closed","source_event_names":["sale_closed"],"revenue_type":"cash","attribution_eligible":true}],"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}}},"x-atribu-scopes":["goals:write"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Create a conversion goal","description":"Creates a new conversion definition that maps event types to conversion metrics.","tags":["Write"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","example":"Trial Signup"},"source_event_names":{"type":"array","items":{"type":"string"},"example":["trial_started"]},"revenue_type":{"type":"string","enum":["cash","pipeline","gross"]},"attribution_eligible":{"type":"boolean"}},"required":["name","source_event_names","revenue_type"]}}}},"responses":{"200":{"description":"Goal created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"display_name":{"type":"string"},"conversion_key":{"type":"string"}},"required":["id","display_name","conversion_key"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["goals:write"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile","deprecated":true,"x-atribu-sunset":{"deprecatedAt":"2026-09-05","sunsetAt":"2026-12-04","successor":"/api/v1/goals/definitions"}},"delete":{"summary":"Delete a conversion goal","description":"Deletes a conversion definition by ID. Pass `id` as a query parameter.","tags":["Write"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"query"}],"responses":{"200":{"description":"Goal deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"deleted":{"type":"boolean"}},"required":["deleted"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["goals:write"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/attribution/recompute":{"post":{"summary":"Trigger attribution recompute","description":"Queues a full attribution recompute for this profile. Returns 202 Accepted. Heavy endpoint — rate limited to prevent abuse.","tags":["Write"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"202":{"description":"Recompute queued","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"status":{"type":"string"},"message":{"type":"string"}},"required":["status","message"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/messages":{"post":{"summary":"Send a channel-agnostic message","description":"Sends a WhatsApp or Instagram message via a connected `data_connection`. Requires the `whatsapp` or `instagram` scope on the API key (matching the `channel`). For keys minted through the OAuth provider flow, an active `oauth_app_authorizations` row must also exist for the (app, profile, data_connection).\n\n**WhatsApp media (image/video/audio/document)** accepts either `media.media_id` (pre-uploaded via Meta's `/media` endpoint, 30-day cache) or `media.link` (public HTTPS URL Meta fetches once per send — no caching, size caps enforced). High-fanout sends should pre-upload.\n\n**Instagram image/video** accept `image_url` / `video_url` directly (Meta fetches the URL). Quick replies are IG-only.\n\n**WhatsApp Flows** (`content.type='flow'`) send a Meta-hosted multi-screen form behind a CTA button. Manage flows via `/api/v1/whatsapp/flows`; the customer's answers arrive on the `message.received` webhook as `raw.interactive.nfm_reply.response_json`.\n\n**Email is capped per mailbox per UTC day** (#341). Atribu holds each connected mailbox well under its provider's own daily send limit — crossing that limit suspends the mailbox itself for up to 24 hours — so a send beyond the cap answers **429** and outbound resumes automatically at the next UTC midnight. Defaults are 300/day for a personal-domain mailbox (gmail.com, outlook.com, …) and 1,500/day for a Google Workspace / Microsoft 365 one; the effective cap is per connection and can be raised. Inbound sync is never paused.","tags":["Messaging"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"channel":{"type":"string","enum":["whatsapp","instagram","email"]},"to":{"type":"string","description":"E.164 phone for WhatsApp; IGSID for Instagram; recipient email address for Email."},"content":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["text"]},"text":{"type":"string","description":"Up to 4096 chars."}},"required":["type","text"]},{"type":"object","properties":{"type":{"type":"string","enum":["template"]},"template_name":{"type":"string"},"language_code":{"type":"string"},"components":{"type":"array","items":{"type":"object","additionalProperties":{}}}},"required":["type","template_name","language_code"],"description":"WhatsApp only — pre-approved message templates outside the 24h service window."},{"type":"object","properties":{"type":{"type":"string","enum":["image"]},"image_url":{"type":"string","format":"uri","description":"Instagram path."},"media":{"type":"object","properties":{"media_id":{"type":"string","description":"Pre-uploaded handle from POST /{phone-number-id}/media. Valid 30 days. Meta-cached."},"link":{"type":"string","format":"uri","description":"Publicly accessible HTTPS URL. Meta fetches at send-time; no auth, no caching, size caps enforced server-side."}},"description":"WhatsApp path — either media_id or link."},"caption":{"type":"string"}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["video"]},"video_url":{"type":"string","format":"uri","description":"Instagram path — public HTTPS URL Meta fetches as a `video` attachment."},"media":{"type":"object","properties":{"media_id":{"type":"string","description":"Pre-uploaded handle from POST /{phone-number-id}/media. Valid 30 days. Meta-cached."},"link":{"type":"string","format":"uri","description":"Publicly accessible HTTPS URL. Meta fetches at send-time; no auth, no caching, size caps enforced server-side."}},"description":"WhatsApp path — either media_id or link."},"caption":{"type":"string","description":"WhatsApp only; ignored on Instagram."}},"required":["type"],"description":"WhatsApp (media) or Instagram (video_url)."},{"type":"object","properties":{"type":{"type":"string","enum":["audio"]},"media":{"type":"object","properties":{"media_id":{"type":"string","description":"Pre-uploaded handle from POST /{phone-number-id}/media. Valid 30 days. Meta-cached."},"link":{"type":"string","format":"uri","description":"Publicly accessible HTTPS URL. Meta fetches at send-time; no auth, no caching, size caps enforced server-side."}}}},"required":["type","media"],"description":"WhatsApp only."},{"type":"object","properties":{"type":{"type":"string","enum":["document"]},"media":{"type":"object","properties":{"media_id":{"type":"string","description":"Pre-uploaded handle from POST /{phone-number-id}/media. Valid 30 days. Meta-cached."},"link":{"type":"string","format":"uri","description":"Publicly accessible HTTPS URL. Meta fetches at send-time; no auth, no caching, size caps enforced server-side."}}},"filename":{"type":"string"},"caption":{"type":"string"}},"required":["type","media","filename"],"description":"WhatsApp only."},{"type":"object","properties":{"type":{"type":"string","enum":["interactive_buttons"]},"body":{"type":"string","description":"Body text shown above the buttons (1024 char max)."},"header":{"type":"string","description":"Optional text header (60 char max)."},"buttons":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Postback ID returned when tapped."},"title":{"type":"string","description":"Button label (20 char max)."}},"required":["id","title"]}}},"required":["type","body","buttons"],"description":"WhatsApp only — up to 3 reply buttons. Lands as an `interactive` message; tap returns the button `id` on the `messaging_postbacks` webhook field."},{"type":"object","properties":{"type":{"type":"string","enum":["flow"]},"flow_id":{"type":"string","description":"Meta flow id (numeric string). Must be PUBLISHED unless `mode='draft'`."},"body":{"type":"string","description":"Body text shown above the CTA button (1024 char max)."},"cta":{"type":"string","description":"CTA button label — Meta caps at 30 chars."},"header":{"anyOf":[{"type":"string"},{"type":"object","properties":{"type":{"type":"string","enum":["image","video","document"]},"media_id":{"type":"string","description":"Pre-uploaded media id — exactly one of media_id/link."},"link":{"type":"string","description":"Public HTTPS URL — exactly one of media_id/link."}},"required":["type"]}],"description":"Optional header: a string (text header, 60 char max) or a media object { type: image|video|document, media_id | link }. Media headers on interactive flow messages are a permissive passthrough — Meta's docs list only text here, and Graph is the enforcer; a refusal surfaces as a classified provider error."},"footer":{"type":"string","description":"Optional footer (60 char max)."},"flow_token":{"type":"string","description":"Opaque session token echoed back in the customer's flow response (`nfm_reply.response_json`). Omit → Meta defaults to 'unused'. Never logged by Atribu."},"flow_action":{"type":"string","enum":["navigate","data_exchange"],"description":"Omit → 'navigate'."},"action_payload":{"type":"object","properties":{"screen":{"type":"string","description":"First screen to render."},"data":{"type":"object","additionalProperties":{}}},"required":["screen"],"description":"First screen + its data. Valid with flow_action 'navigate' only."},"mode":{"type":"string","enum":["draft","published"],"description":"Omit → 'published'. 'draft' sends an unpublished flow for testing."}},"required":["type","flow_id","body","cta"],"description":"WhatsApp only — sends a WhatsApp Flow (Meta-hosted multi-screen form) behind a CTA button. The customer's answers come back on the `message.received` webhook as `raw.interactive.nfm_reply.response_json`."},{"type":"object","properties":{"type":{"type":"string","enum":["location"]},"latitude":{"type":"number","description":"Latitude, -90 to 90."},"longitude":{"type":"number","description":"Longitude, -180 to 180."},"name":{"type":"string","description":"Place name shown on the pin."},"address":{"type":"string","description":"Street address shown under the name."}},"required":["type","latitude","longitude"],"description":"WhatsApp only — sends a location pin the customer can open in their maps app."},{"type":"object","properties":{"type":{"type":"string","enum":["quick_replies"]},"text":{"type":"string"},"quick_replies":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"payload":{"type":"string"}},"required":["title","payload"]}}},"required":["type","text","quick_replies"],"description":"Instagram only — up to 13 quick replies."},{"type":"object","properties":{"type":{"type":"string","enum":["email"]},"subject":{"type":"string","description":"Subject line. Omit on a reply to keep the thread subject."},"text":{"type":"string","description":"Plain-text body."},"html":{"type":"string","description":"HTML body. Provide text and/or html."},"in_reply_to":{"type":"string","description":"RFC822 Message-ID being replied to (round-trip `rfc822_message_id` from the inbound event)."},"references":{"type":"array","items":{"type":"string"},"description":"RFC822 References chain (round-trip from the inbound event)."},"thread_id":{"type":"string","description":"Gmail threadId / Outlook conversationId — keeps the reply in-thread."},"reply_to_message_id":{"type":"string","description":"Outlook: the inbound message id to `/reply` against (auto-threads). Ignored for Gmail."},"cc":{"type":"array","items":{"type":"string"},"description":"CC recipient emails."},"attachments":{"type":"array","items":{"type":"object","properties":{"filename":{"type":"string","description":"Attachment filename shown to the recipient."},"mime_type":{"type":"string","description":"MIME type, e.g. image/jpeg or application/pdf."},"url":{"type":"string","description":"Public HTTPS URL fetched server-side at send time. Provide exactly one of `url` or `data_base64`."},"data_base64":{"type":"string","description":"Inline base64 file bytes. Provide exactly one of `url` or `data_base64`."}},"required":["filename","mime_type"]},"description":"File attachments (max 10, 3MB total per message). Each is a public URL fetched at send time or inline base64."}},"required":["type"],"description":"Email only (Gmail/Outlook). Provide text and/or html; carry the thread anchors from the inbound event to reply in-thread. Optional `attachments` (max 10, 3MB total) as public URLs or inline base64."}]},"human_agent":{"type":"boolean","description":"Instagram only. When true, the message is sent with Meta's HUMAN_AGENT tag, letting an authorized human agent reply outside the 24-hour window (up to 7 days). Set this ONLY for messages genuinely sent by a human agent — never for automated/bot replies, which would violate Meta's messaging policy. Ignored on WhatsApp."}},"required":["connection_id","channel","to","content"]}}}},"responses":{"200":{"description":"Message sent","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"channel":{"type":"string","enum":["whatsapp","instagram","email"]},"to":{"type":"string"},"provider_message_id":{"type":"string"},"sent_at":{"type":"string"}},"required":["connection_id","channel","to","provider_message_id","sent_at"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or OAuth-app not authorized for this connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready (status != connected)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error (content shape unsupported for this channel)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Email only — the mailbox reached its daily send cap; outbound resumes at the next UTC midnight","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta send failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/messages/typing":{"post":{"summary":"Mark a WhatsApp message read (with or without a typing indicator)","description":"Marks the customer's inbound message read and, by default, shows the \"typing…\" bubble, so an agent that takes tens of seconds to compose a reply produces the signals a human agent would. One Meta call: the indicator is a field on the read receipt, so **the read receipt is not optional** — Meta offers no way to show typing without marking read.\n\n**Two modes, via `indicator`.** `typing` (default) = read receipt + bubble, for an agent that is composing. `read` = read receipt only, for a human who just opened the conversation in your inbox — blue ticks without a bubble that would promise a reply nobody made. Everything else on this route is identical between the two, which is why they share one endpoint. Consumers written before `indicator` existed send nothing and keep the original behaviour.\n\nAuth, scope (`whatsapp`) and connection resolution are identical to `POST /api/v1/messages`, including the `oauth_app_authorizations` grant check for OAuth-flow-minted keys.\n\n**Best-effort.** One upstream call, no retries, no queueing, a 4-second upstream budget. The indicator shows for at most 25 seconds and is dismissed the moment you send a message on that thread — there is nothing to cancel and no \"typing off\" call.\n\n**`message_id` is the INBOUND wamid** (the customer's message), not one of yours — Meta derives the recipient from it. An unknown or stale wamid (older than ~24h, already read, number re-registered) answers **200** with `forwarded: false`, not an error: none of those is a caller fault or has a remedy, and non-2xx is reserved for faults somebody can act on.\n\n**This route never answers 404.** An unknown, wrong-profile or unauthorized `connection_id` answers 403 — consumers treat 404 on this path as \"typing is not deployed on this bridge\" and disable the feature.\n\nWhatsApp only today; Instagram and Messenger are the intended extension of this same endpoint (which is why `to` is required even though WhatsApp does not need it). Email has no typing concept.","tags":["Messaging"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid","description":"Target WhatsApp `data_connection`."},"channel":{"type":"string","enum":["whatsapp"],"description":"`whatsapp` only today; anything else is a 400."},"to":{"type":"string","description":"The customer's wa_id (conversation external id). Redundant for the Meta call itself — Meta derives the recipient from `message_id` — but load-bearing here: it is the only thing that can catch a `message_id` from a different conversation (see below), it is logged truncated on every non-delivery, and it is what the future Instagram/Messenger extension of this endpoint keys on."},"message_id":{"type":"string","description":"The INBOUND wamid the receipt anchors to (e.g. `wamid.HBgLNTY5…`). Unknown/stale values are a 200 no-op. When the wamid is one Atribu has stored AND its conversation belongs to somebody other than `to`, the request is a 200 no-op with `reason: \"message_id_conversation_mismatch\"` and is NOT forwarded — a mismatched pair would blue-tick (and, in `typing` mode, animate) a thread you did not mean to touch."},"indicator":{"type":"string","enum":["typing","read"],"default":"typing","description":"Which signal to send. `typing` (the default) marks the message read AND shows the \"typing…\" bubble — use it when an agent is composing a reply. `read` marks the message read ONLY, with no bubble — use it when a human opened the conversation in your inbox: blue ticks say \"a person saw this\", while a bubble would promise a reply within 25 seconds that nobody made.\n\nOmit it for the original behaviour. The applied mode is echoed back in `data.indicator`; if that field is absent the deployment predates this option and showed a bubble regardless."}},"required":["connection_id","channel","to","message_id"]}}}},"responses":{"200":{"description":"Forwarded to Meta, or knowingly no-op'd (`forwarded: false`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TypingIndicatorResponse"}}}},"400":{"description":"Malformed body, or a channel other than `whatsapp`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Invalid API key, or the connection's Meta token was rejected (reconnect the channel)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"The WhatsApp Business Account has no working payment method (Meta 131042) — add one and the call succeeds unchanged","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Missing `whatsapp` scope, OR the `connection_id` is unknown / belongs to another profile / is not authorized for this OAuth app. Deliberately 403 rather than 404 — see the description.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready (disconnected, or no WhatsApp phone number yet)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Meta rejected the call permanently for a reason other than the `message_id` (which is a 200 no-op)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limited. This route has its own bucket, so it cannot consume the send route's allowance.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta call failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/media":{"post":{"summary":"Upload WhatsApp media (pre-upload → media_id)","description":"Uploads a binary to Meta's WhatsApp `/{phone-number-id}/media` endpoint and returns a `media_id` (Meta-cached ~30 days). Reference it on `POST /api/v1/messages` as `content.media.media_id`.\n\nPrefer this over `media.link` for reliability: with a media_id Meta never fetches your URL, so origin-hosted, private, short-lived, or non-public media works — a `link` requires Meta to fetch a public HTTPS URL at send time. It's also the right path for high-fanout sends.\n\n**WhatsApp only** (Instagram fetches public URLs directly via `image_url` / `video_url`). Requires the `whatsapp` scope. Size caps: image 5MB, sticker 500KB, video/audio 16MB, document 100MB.","tags":["Messaging"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid","description":"Target WhatsApp `data_connection`."},"file":{"type":"string","format":"binary","description":"The media binary."}},"required":["connection_id","file"]}}}},"responses":{"200":{"description":"Uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MediaUploadResponse"}}}},"403":{"description":"Missing `whatsapp` scope or OAuth-app not authorized for this connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready (status != connected)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"File exceeds the WhatsApp size cap for its type","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Missing/invalid `file` or `connection_id`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta upload failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/media/{mediaId}":{"get":{"summary":"Resolve WhatsApp media (media_id → hosted URL)","description":"Resolves an inbound WhatsApp `media_id` to a hosted, browser-fetchable URL. Meta's own media URL is auth-gated and expires ~5 minutes, so Atribu downloads the bytes and re-hosts them, returning a signed URL (~7-day TTL) — the same shape Instagram inbound attachments use.\n\nNote: Atribu also embeds this URL directly in the WhatsApp webhook fan-out as `attachments:[{type,payload:{url}}]`, so most consumers never need to call this. Use it when you prefer to resolve media on demand. WhatsApp media IDs from webhooks expire after 7 days. Requires the `whatsapp` scope.","tags":["Messaging"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The inbound WhatsApp `media_id` from the webhook."},"required":true,"description":"The inbound WhatsApp `media_id` from the webhook.","name":"mediaId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"The WhatsApp `data_connection` the media belongs to."},"required":true,"description":"The WhatsApp `data_connection` the media belongs to.","name":"connection_id","in":"query"}],"responses":{"200":{"description":"Resolved","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WhatsAppMediaResolveResponse"}}}},"403":{"description":"Missing `whatsapp` scope or OAuth-app not authorized for this connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"410":{"description":"Media expired or not found (WhatsApp media IDs expire after 7 days)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Missing/invalid `mediaId` or `connection_id`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta media fetch failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/comments/{comment_id}/private-reply":{"post":{"summary":"Send a DM to the user who left an IG comment","description":"Targets Meta's `recipient.comment_id` send path — the message lands as a DM in the comment-to-DM thread. Requires `instagram` scope.","tags":["Messaging"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"comment_id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"text":{"type":"string","minLength":1,"maxLength":1000}},"required":["connection_id","text"]}}}},"responses":{"200":{"description":"Private reply sent","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"comment_id":{"type":"string"},"kind":{"type":"string","enum":["private_reply","public_reply"]},"connection_id":{"type":"string","format":"uuid"},"provider_message_id":{"type":"string"},"sent_at":{"type":"string"}},"required":["comment_id","kind","connection_id","provider_message_id","sent_at"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/comments/{comment_id}/reply":{"post":{"summary":"Post a public reply on an IG comment thread","description":"Posts directly to `/<comment-id>/replies` — visible to everyone, not a DM. Requires `instagram` scope.","tags":["Messaging"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"comment_id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"text":{"type":"string","minLength":1,"maxLength":1000}},"required":["connection_id","text"]}}}},"responses":{"200":{"description":"Public reply posted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"comment_id":{"type":"string"},"kind":{"type":"string","enum":["private_reply","public_reply"]},"connection_id":{"type":"string","format":"uuid"},"provider_message_id":{"type":"string"},"sent_at":{"type":"string"}},"required":["comment_id","kind","connection_id","provider_message_id","sent_at"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/email/search":{"post":{"summary":"Search threads in a connected mailbox","description":"Searches a connected Gmail/Outlook mailbox and returns matching threads. `query` is Gmail search syntax (`from:`, `subject:`, `is:unread`, `newer_than:7d`) or Outlook KQL. Gmail returns thread refs (snippet only); Outlook search returns messages deduped to conversations (subject + preview). Requires the `email` scope.","tags":["Email"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"query":{"type":"string","minLength":1,"maxLength":2048},"max":{"type":"integer","minimum":1,"maximum":100,"description":"Max threads (default 25)."},"cursor":{"type":"string","description":"Gmail pageToken passthrough; Outlook search is unpaginated."}},"required":["connection_id","query"]}}}},"responses":{"200":{"description":"Matching threads","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"threads":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Provider thread key — Gmail threadId / Outlook conversationId."},"snippet":{"type":["string","null"]},"subject":{"type":["string","null"],"description":"Populated for Outlook search; null for Gmail thread refs."}},"required":["id","snippet","subject"]}},"next_cursor":{"type":["string","null"]}},"required":["threads","next_cursor"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing `email` scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready / needs reconnection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream provider error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["email"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/email/threads/{id}":{"get":{"summary":"Get a normalized email thread","description":"Returns every message in a thread, normalized to a provider-agnostic shape (oldest-first). `{id}` is the provider thread key (Gmail threadId / Outlook conversationId) — round-trip it from search results or the inbound `message.received` webhook. Requires the `email` scope.","tags":["Email"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"Thread messages","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"thread_id":{"type":"string"},"messages":{"type":"array","items":{"type":"object","properties":{"message_id":{"type":"string"},"thread_id":{"type":"string"},"rfc822_message_id":{"type":["string","null"]},"in_reply_to":{"type":["string","null"]},"references":{"type":"array","items":{"type":"string"}},"from":{"type":["object","null"],"properties":{"email":{"type":"string"},"name":{"type":["string","null"]}},"required":["email","name"]},"to":{"type":"array","items":{"type":"object","properties":{"email":{"type":"string"},"name":{"type":["string","null"]}},"required":["email","name"]}},"cc":{"type":"array","items":{"type":"object","properties":{"email":{"type":"string"},"name":{"type":["string","null"]}},"required":["email","name"]}},"subject":{"type":["string","null"]},"text_body":{"type":["string","null"]},"html_body":{"type":["string","null"]},"attachments":{"type":"array","items":{"type":"object","properties":{"id":{"type":["string","null"]},"filename":{"type":["string","null"]},"mime_type":{"type":["string","null"]},"size_bytes":{"type":["number","null"]}},"required":["id","filename","mime_type","size_bytes"]}},"sent_at":{"type":"string"},"direction":{"type":"string","enum":["inbound","outbound"]}},"required":["message_id","thread_id","rfc822_message_id","in_reply_to","references","from","to","cc","subject","text_body","html_body","attachments","sent_at","direction"]}}},"required":["thread_id","messages"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing `email` scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready / needs reconnection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["email"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/email/drafts":{"post":{"summary":"Create a draft (reply or new)","description":"Creates a draft in the connected mailbox. Gmail builds a raw RFC822 draft attached to `thread_id` (carry `in_reply_to`/`references` to thread in other clients). Outlook uses `/createReply` when `reply_to_message_id` is given (auto-threads + auto-fills recipients), else a standalone draft. Provide `text` and/or `html`. Requires the `email` scope.","tags":["Email"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"thread_id":{"type":"string","description":"Gmail threadId / Outlook conversationId."},"reply_to_message_id":{"type":"string","description":"Outlook: inbound message id to `/createReply` against."},"in_reply_to":{"type":"string","description":"RFC822 Message-ID being replied to (Gmail headers)."},"references":{"type":"array","items":{"type":"string"},"description":"RFC822 References chain (Gmail headers)."},"subject":{"type":"string"},"text":{"type":"string"},"html":{"type":"string"},"to":{"type":"array","items":{"type":"string"},"description":"Recipient emails. Required for Gmail and for standalone Outlook drafts."},"cc":{"type":"array","items":{"type":"string"}}},"required":["connection_id"]}}}},"responses":{"200":{"description":"Draft created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"draft_id":{"type":"string"},"thread_id":{"type":["string","null"]}},"required":["draft_id","thread_id"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing `email` scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready / needs reconnection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error (missing body or required recipients)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["email"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/email/threads/{id}/labels":{"post":{"summary":"Add/remove labels on a thread","description":"Applies label changes to every message in a thread. Pass label NAMES — they resolve to Gmail labelIds server-side (call `GET /api/v1/email/labels` for valid names). Unknown `add` names 422; unknown `remove` names are a no-op. Outlook category mapping is a fast-follow (501). Requires the `email` scope.","tags":["Email"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"add":{"type":"array","items":{"type":"string"},"description":"Label names to add."},"remove":{"type":"array","items":{"type":"string"},"description":"Label names to remove."}},"required":["connection_id"]}}}},"responses":{"200":{"description":"Labels applied","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"thread_id":{"type":"string"},"added":{"type":"array","items":{"type":"string"}},"removed":{"type":"array","items":{"type":"string"}}},"required":["thread_id","added","removed"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing `email` scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Unknown label name / empty change set","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"501":{"description":"Outlook thread labeling is a fast-follow","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["email"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/email/labels":{"get":{"summary":"List a mailbox's labels","description":"Returns the connected mailbox's labels (Gmail) or master categories (Outlook — its label analog). Names map back to the thread-labels endpoint. Requires the `email` scope.","tags":["Email"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"Labels","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"labels":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":["string","null"],"description":"Gmail: 'system'|'user'. Outlook: 'category'."}},"required":["id","name","type"]}}},"required":["labels"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"example":{"data":{"labels":[{"id":"Label_1","name":"Leads","type":"user"}]},"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"403":{"description":"Missing `email` scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready / needs reconnection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["email"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/calendar/events":{"post":{"summary":"Create a calendar event","description":"Creates an event on an Atribu booking calendar. `calendar_id` is required and must be an Atribu-created booking calendar for the connection (creating events on `primary`/unknown calendars is rejected with `calendar_unsupported` 422). `start`/`end` are required — pass `date_time` (timed) or `date` (all-day). `extended_private` round-trips back on the inbound `calendar.event.changed` fan-out, so consumers can tag their own link id (e.g. `vitrina_appointment_id`). `send_updates` controls whether Google emails attendees. Requires the `calendar` scope.","tags":["Calendar"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"calendar_id":{"type":"string","minLength":1,"maxLength":1024,"description":"An Atribu-created booking calendar id for the connection."},"summary":{"type":"string","maxLength":1024},"description":{"type":"string","maxLength":8192},"location":{"type":"string","maxLength":1024},"start":{"type":"object","properties":{"date_time":{"type":"string","minLength":1,"maxLength":64,"description":"RFC3339 timestamp for a timed event."},"date":{"type":"string","minLength":1,"maxLength":32,"description":"YYYY-MM-DD for an all-day event."},"time_zone":{"type":"string","minLength":1,"maxLength":64,"description":"IANA tz id (e.g. America/Santiago)."}},"description":"A timed (`date_time`) OR all-day (`date`) point."},"end":{"type":"object","properties":{"date_time":{"type":"string","minLength":1,"maxLength":64,"description":"RFC3339 timestamp for a timed event."},"date":{"type":"string","minLength":1,"maxLength":32,"description":"YYYY-MM-DD for an all-day event."},"time_zone":{"type":"string","minLength":1,"maxLength":64,"description":"IANA tz id (e.g. America/Santiago)."}},"description":"A timed (`date_time`) OR all-day (`date`) point."},"attendees":{"type":"array","items":{"type":"object","properties":{"email":{"type":"string","format":"email"},"display_name":{"type":"string","maxLength":256}},"required":["email"]},"maxItems":100},"extended_private":{"type":"object","additionalProperties":{"type":"string"},"description":"extendedProperties.private — your link / loop-prevention tag."},"send_updates":{"type":"string","enum":["all","externalOnly","none"],"description":"Whether Google emails attendees about the change."}},"required":["connection_id","calendar_id","start","end"]}}}},"responses":{"200":{"description":"Event created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","description":"Google event id — the stable dedup/match key."},"status":{"type":"string","description":"confirmed | tentative | cancelled."},"html_link":{"type":["string","null"]},"ical_uid":{"type":["string","null"],"description":"iCalUID — stable across copies/calendars."},"updated":{"type":["string","null"],"description":"RFC3339 last-modification time."},"start":{"type":["object","null"],"properties":{"date_time":{"type":["string","null"]},"date":{"type":["string","null"]},"time_zone":{"type":["string","null"]}},"required":["date_time","date","time_zone"]},"end":{"type":["object","null"],"properties":{"date_time":{"type":["string","null"]},"date":{"type":["string","null"]},"time_zone":{"type":["string","null"]}},"required":["date_time","date","time_zone"]}},"required":["id","status","html_link","ical_uid","updated","start","end"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing `calendar` scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready / needs reconnection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error (e.g. start/end missing date_time and date)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream provider error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["calendar"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/calendar/events/{id}":{"patch":{"summary":"Update a calendar event","description":"Partial update (Google events.patch) — only the supplied fields change. Same body as create, but `start`/`end` are optional. `calendar_id` is required (the Atribu booking calendar the event lives on). `{id}` is the Google event id. Requires the `calendar` scope.","tags":["Calendar"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"calendar_id":{"type":"string","minLength":1,"maxLength":1024,"description":"The Atribu booking calendar the event lives on."},"summary":{"type":"string","maxLength":1024},"description":{"type":"string","maxLength":8192},"location":{"type":"string","maxLength":1024},"start":{"type":"object","properties":{"date_time":{"type":"string","minLength":1,"maxLength":64,"description":"RFC3339 timestamp for a timed event."},"date":{"type":"string","minLength":1,"maxLength":32,"description":"YYYY-MM-DD for an all-day event."},"time_zone":{"type":"string","minLength":1,"maxLength":64,"description":"IANA tz id (e.g. America/Santiago)."}},"description":"A timed (`date_time`) OR all-day (`date`) point."},"end":{"type":"object","properties":{"date_time":{"type":"string","minLength":1,"maxLength":64,"description":"RFC3339 timestamp for a timed event."},"date":{"type":"string","minLength":1,"maxLength":32,"description":"YYYY-MM-DD for an all-day event."},"time_zone":{"type":"string","minLength":1,"maxLength":64,"description":"IANA tz id (e.g. America/Santiago)."}},"description":"A timed (`date_time`) OR all-day (`date`) point."},"attendees":{"type":"array","items":{"type":"object","properties":{"email":{"type":"string","format":"email"},"display_name":{"type":"string","maxLength":256}},"required":["email"]},"maxItems":100},"extended_private":{"type":"object","additionalProperties":{"type":"string"}},"send_updates":{"type":"string","enum":["all","externalOnly","none"],"description":"Whether Google emails attendees about the change."}},"required":["connection_id","calendar_id"]}}}},"responses":{"200":{"description":"Event updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","description":"Google event id — the stable dedup/match key."},"status":{"type":"string","description":"confirmed | tentative | cancelled."},"html_link":{"type":["string","null"]},"ical_uid":{"type":["string","null"],"description":"iCalUID — stable across copies/calendars."},"updated":{"type":["string","null"],"description":"RFC3339 last-modification time."},"start":{"type":["object","null"],"properties":{"date_time":{"type":["string","null"]},"date":{"type":["string","null"]},"time_zone":{"type":["string","null"]}},"required":["date_time","date","time_zone"]},"end":{"type":["object","null"],"properties":{"date_time":{"type":["string","null"]},"date":{"type":["string","null"]},"time_zone":{"type":["string","null"]}},"required":["date_time","date","time_zone"]}},"required":["id","status","html_link","ical_uid","updated","start","end"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing `calendar` scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready / needs reconnection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream provider error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["calendar"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"delete":{"summary":"Delete (cancel) a calendar event","description":"Cancels the event on its Atribu booking calendar. `connection_id` and `calendar_id` (and optional `send_updates`) are query params, not a body. `send_updates` controls whether Google emails attendees. Requires the `calendar` scope.","tags":["Calendar"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"},{"schema":{"type":"string","minLength":1,"maxLength":1024,"description":"The Atribu booking calendar the event lives on."},"required":true,"description":"The Atribu booking calendar the event lives on.","name":"calendar_id","in":"query"},{"schema":{"type":"string","enum":["all","externalOnly","none"],"description":"Whether Google emails attendees about the change."},"required":false,"description":"Whether Google emails attendees about the change.","name":"send_updates","in":"query"}],"responses":{"200":{"description":"Event deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"deleted":{"type":"boolean","enum":[true]},"event_id":{"type":"string"}},"required":["deleted","event_id"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing `calendar` scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready / needs reconnection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Missing connection_id/calendar_id query param, or calendar not Atribu-created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream provider error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["calendar"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/calendar/calendars":{"get":{"summary":"List Atribu booking calendars","description":"Lists the Atribu-created booking calendars for a connection. Names come from Atribu's store (the `calendar.app.created` scope cannot list the account's calendars). Requires the `calendar.manage` scope.","tags":["Calendar"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"Booking calendars","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"The Google calendar id (use as `calendar_id` on event ops)."},"summary":{"type":"string","description":"The booking calendar's display name."},"description":{"type":["string","null"]},"time_zone":{"type":["string","null"],"description":"IANA tz id."}},"required":["id","summary","description","time_zone"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing `calendar.manage` scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready / needs reconnection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["calendar.manage"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Create an Atribu booking calendar","description":"Creates a dedicated Atribu booking calendar in the connected Google account (the user's primary is never touched). Pass an `Idempotency-Key` header to make creation idempotent — a duplicate key for the connection returns the existing calendar. Requires the `calendar.manage` scope (and the connection's Google grant must include `calendar.app.created`).","tags":["Calendar"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"summary":{"type":"string","minLength":1,"maxLength":1024,"description":"Display name for the new booking calendar."},"description":{"type":"string","maxLength":8192},"time_zone":{"type":"string","minLength":1,"maxLength":64,"description":"IANA tz id (e.g. America/Santiago)."}},"required":["connection_id","summary"]}}}},"responses":{"201":{"description":"Booking calendar created (or the existing one for a repeated Idempotency-Key)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","description":"The Google calendar id (use as `calendar_id` on event ops)."},"summary":{"type":"string","description":"The booking calendar's display name."},"description":{"type":["string","null"]},"time_zone":{"type":["string","null"],"description":"IANA tz id."}},"required":["id","summary","description","time_zone"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing `calendar.manage` scope, or the Google grant lacks `calendar.app.created` (`calendar_scope_required`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready / needs reconnection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream provider error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["calendar.manage"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/calendar/calendars/{calendarId}/acl":{"post":{"summary":"Share an Atribu booking calendar","description":"Grants a team member `reader` or `writer` access to an Atribu booking calendar. Idempotent — re-sharing the same email updates the role. `{calendarId}` must be an Atribu-created booking calendar. Requires the `calendar.manage` scope (and the Google grant must include `calendar.acls`).","tags":["Calendar"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"calendarId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"email":{"type":"string","maxLength":320,"format":"email","description":"Team member to grant access to."},"role":{"type":"string","enum":["reader","writer"]}},"required":["connection_id","email","role"]}}}},"responses":{"200":{"description":"Share created/updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","description":"Opaque ACL rule id (e.g. `user:agent@dealer.com`) — pass to DELETE."},"role":{"type":"string","description":"reader | writer."},"email":{"type":["string","null"],"description":"The grantee email (for user-scope rules)."}},"required":["id","role","email"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing `calendar.manage` scope, or the Google grant lacks `calendar.acls` (`calendar_scope_required`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready / needs reconnection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error, or `{calendarId}` is not an Atribu-created calendar (`calendar_unsupported`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream provider error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["calendar.manage"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"get":{"summary":"List shares on an Atribu booking calendar","description":"Lists the ACL rules on an Atribu booking calendar. `connection_id` is a query param. Requires the `calendar.manage` scope.","tags":["Calendar"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"calendarId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"ACL rules","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Opaque ACL rule id (e.g. `user:agent@dealer.com`) — pass to DELETE."},"role":{"type":"string","description":"reader | writer."},"email":{"type":["string","null"],"description":"The grantee email (for user-scope rules)."}},"required":["id","role","email"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing `calendar.manage` scope, or the Google grant lacks `calendar.acls`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready / needs reconnection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`{calendarId}` is not an Atribu-created calendar (`calendar_unsupported`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["calendar.manage"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/calendar/calendars/{calendarId}/acl/{ruleId}":{"delete":{"summary":"Revoke a share on an Atribu booking calendar","description":"Revokes a team member's access (by the ACL `{ruleId}` from the share/list responses). `connection_id` is a query param. Idempotent — an already-removed rule still returns success. Requires the `calendar.manage` scope.","tags":["Calendar"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"calendarId","in":"path"},{"schema":{"type":"string"},"required":true,"name":"ruleId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"Share revoked","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"deleted":{"type":"boolean","enum":[true]},"rule_id":{"type":"string"}},"required":["deleted","rule_id"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing `calendar.manage` scope, or the Google grant lacks `calendar.acls`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready / needs reconnection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`{calendarId}` is not an Atribu-created calendar (`calendar_unsupported`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream provider error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["calendar.manage"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/calendar/read/calendars":{"get":{"summary":"List external calendars (read-only)","description":"Lists the calendars the connected account can see, for the read-only overlay. `connection_id` must be a read connection (`google_calendar_read` / `microsoft_calendar_read`). Google is live; Microsoft returns 501 `provider_not_configured` until an Entra app registration exists. Requires the `calendar.read` scope.","tags":["Calendar"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"External calendars","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Provider calendar id (use as `calendar_id` on read/events)."},"summary":{"type":"string","description":"Calendar display name."},"description":{"type":["string","null"]},"color":{"type":["string","null"],"description":"Display color (hex) the user assigned, if any."},"primary":{"type":"boolean","description":"True for the account's primary calendar."},"access_role":{"type":"string","enum":["owner","writer","reader","freeBusyReader"],"description":"The connected account's access to this calendar."}},"required":["id","summary","description","color","primary","access_role"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"example":{"data":[{"id":"primary","summary":"Consultas — Clínica Central","description":null,"color":"#4285F4","primary":true,"access_role":"owner"}],"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"403":{"description":"Missing `calendar.read` scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready / needs reconnection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"501":{"description":"Provider not configured on this deployment (Microsoft)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["calendar.read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/calendar/read/events":{"get":{"summary":"List external calendar events (read-only)","description":"Lists events on one external calendar. Bound a window with `time_min`/`time_max` (RFC3339, ≤ 90 days) OR pass a `sync_token` for incremental changes (mutually exclusive). `page_token` continues either; `limit` is 1–2500 (default 250). Recurring series are expanded to instances. `next_sync_token` appears on the final page — persist it as the incremental cursor. Requires the `calendar.read` scope.","tags":["Calendar"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"},{"schema":{"type":"string","minLength":1,"maxLength":1024},"required":true,"name":"calendar_id","in":"query"},{"schema":{"type":"string","description":"RFC3339 lower bound (required unless sync_token)."},"required":false,"description":"RFC3339 lower bound (required unless sync_token).","name":"time_min","in":"query"},{"schema":{"type":"string","description":"RFC3339 upper bound (required unless sync_token)."},"required":false,"description":"RFC3339 upper bound (required unless sync_token).","name":"time_max","in":"query"},{"schema":{"type":"string","description":"Incremental cursor from a prior next_sync_token."},"required":false,"description":"Incremental cursor from a prior next_sync_token.","name":"sync_token","in":"query"},{"schema":{"type":"string"},"required":false,"name":"page_token","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":2500,"description":"Page size, default 250."},"required":false,"description":"Page size, default 250.","name":"limit","in":"query"}],"responses":{"200":{"description":"External calendar events (one page)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Provider event id (recurring series expanded to instances)."},"summary":{"type":["string","null"]},"start":{"type":"object","properties":{"date_time":{"type":["string","null"],"description":"RFC3339 timestamp for a timed event."},"date":{"type":["string","null"],"description":"YYYY-MM-DD for an all-day event."},"time_zone":{"type":["string","null"],"description":"IANA tz id."}},"required":["date_time","date","time_zone"]},"end":{"type":"object","properties":{"date_time":{"type":["string","null"],"description":"RFC3339 timestamp for a timed event."},"date":{"type":["string","null"],"description":"YYYY-MM-DD for an all-day event."},"time_zone":{"type":["string","null"],"description":"IANA tz id."}},"required":["date_time","date","time_zone"]},"all_day":{"type":"boolean"},"status":{"type":"string","enum":["confirmed","tentative","cancelled"]},"updated":{"type":["string","null"],"description":"RFC3339 last-modification time."},"transparency":{"type":"string","enum":["opaque","transparent"],"description":"opaque = busy (blocks the slot); transparent = free."}},"required":["id","summary","start","end","all_day","status","updated","transparency"]}},"meta":{"$ref":"#/components/schemas/Meta"},"next_sync_token":{"type":["string","null"]},"next_page_token":{"type":["string","null"]}},"required":["data","meta","next_sync_token","next_page_token"]}}}},"400":{"description":"The time window exceeds 90 days (`invalid_window`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Missing `calendar.read` scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready / needs reconnection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"410":{"description":"sync_token expired — drop it and re-sync with a window","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error (bad params, or sync_token + window together)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"501":{"description":"Provider not configured on this deployment (Microsoft)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["calendar.read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/conversations/{id}/messages":{"get":{"summary":"List messages in a WhatsApp conversation","description":"Newest-first page of messages for one WhatsApp conversation. `{id}` is the `conversations.id`, never a `whatsapp_account_id` or an external id — a conversation belonging to another profile, or on a channel other than `whatsapp`, answers 404.\n\nPaginate with `?before=<oldest returned message_timestamp>` rather than an opaque cursor — `pagination.cursor` is never populated on this route; use `pagination.has_next` to decide whether to ask again.","tags":["Messaging"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"An ISO timestamp; returns messages strictly older than it."},"required":false,"description":"An ISO timestamp; returns messages strictly older than it.","name":"before","in":"query"},{"schema":{"type":"string","description":"1-100. Defaults to 50."},"required":false,"description":"1-100. Defaults to 50.","name":"limit","in":"query"}],"responses":{"200":{"description":"A page of conversation messages","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WhatsAppConversationMessage"}},"pagination":{"$ref":"#/components/schemas/Pagination"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","pagination","meta"]},"example":{"data":[{"id":"a7b8c9d0-e1f2-4a3b-4c5d-6e7f8a9b0c1d","wamid":"wamid.HBgLNTY5MTIzNDU2NzgVAgARGBI","direction":"inbound","message_type":"text","text_body":"¿Tienen stock del modelo queen?","caption":null,"media_id":null,"media_mime_type":null,"media_filename":null,"interactive_type":null,"interactive_payload":null,"template_name":null,"status":"read","error_code":null,"error_title":null,"is_ai_generated":false,"message_timestamp":"2026-08-28T02:00:00.000Z"}],"pagination":{"has_next":false},"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"403":{"description":"Missing scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No such conversation for this profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Invalid conversation id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp","whatsapp:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Send a text or media message into a WhatsApp conversation","description":"`multipart/form-data` with `text` and/or `file` — at least one is required. A file is uploaded to Meta first (image/video/audio/document, inferred from its content type) and sent as that message type with `text` as its caption (ignored for audio, which carries no caption). Text-only when no file is present.\n\nRefuses with `whatsapp_service_window_expired` (422) when the conversation's 24h customer-service window has closed — use the template or interactive send instead. Per-type size caps: 5MB image, 16MB video/audio, 100MB document (`payload_too_large`, 413).","tags":["Messaging"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"text":{"type":"string","maxLength":4096},"file":{"type":"string","format":"binary"}}}}}},"responses":{"201":{"description":"Message sent","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WhatsAppConversationMessage"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"400":{"description":"Neither text nor file provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Missing whatsapp scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No such conversation for this profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"File exceeds the per-type size cap","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Service window expired, or invalid conversation id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta send failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/conversations/{id}/messages/interactive":{"post":{"summary":"Send an interactive button message into a WhatsApp conversation","description":"Sends inside the 24h customer-service window, same as a text message — Meta does not exempt interactive sends from it, unlike a template send.","tags":["Messaging"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"body":{"type":"string","minLength":1,"maxLength":1024},"buttons":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1},"title":{"type":"string","minLength":1,"maxLength":20}},"required":["id","title"]},"minItems":1,"maxItems":3,"description":"WhatsApp reply buttons (max 3, title max 20 chars)."},"header":{"type":"string","minLength":1,"maxLength":60}},"required":["body","buttons"]}}}},"responses":{"201":{"description":"Message sent","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WhatsAppConversationMessage"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing whatsapp scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No such conversation for this profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Invalid body or conversation id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta send failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/conversations/{id}/messages/template":{"post":{"summary":"Send a template message into a WhatsApp conversation","description":"The out-of-window re-engagement path — sendable even after the conversation's 24h customer-service window has closed, unlike text/media/interactive sends. `template_name` must already be `APPROVED` (see `GET /api/v1/whatsapp/templates`).","tags":["Messaging"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"template_name":{"type":"string","minLength":1,"maxLength":512},"language_code":{"type":"string","minLength":2,"maxLength":10},"components":{"type":"array","items":{"type":"object","additionalProperties":{}}}},"required":["template_name","language_code","components"]}}}},"responses":{"201":{"description":"Message sent","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WhatsAppConversationMessage"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing whatsapp scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No such conversation for this profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Invalid body or conversation id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta send failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/templates":{"get":{"summary":"List WhatsApp message templates","description":"Returns the connection's message templates from Atribu's mirrored cache (all statuses) — no live Meta call. Each row carries `quality_score`, `status_changed_at` and `last_synced_at`. Populate/refresh the cache with `POST /api/v1/whatsapp/templates/sync` (or rely on the reconciliation cron / webhook push). Templates can be sent via `POST /api/v1/messages` with `content.type='template'` only when `status='APPROVED'`.","tags":["WhatsApp Templates"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"Templates list (from cache)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":["string","null"],"description":"Meta template id."},"name":{"type":"string"},"language":{"type":"string"},"category":{"type":["string","null"]},"status":{"type":"string","description":"One of APPROVED, PENDING, REJECTED, PAUSED, DISABLED, IN_APPEAL, LIMIT_EXCEEDED, PENDING_DELETION, DELETED, ARCHIVED."},"quality_score":{"type":["string","null"],"description":"Meta per-template quality: GREEN | YELLOW | RED | UNKNOWN."},"components":{"type":["array","null"],"items":{"type":"object","additionalProperties":{}}},"status_changed_at":{"type":["string","null"],"description":"When the status last transitioned (ISO)."},"last_synced_at":{"type":["string","null"],"description":"When this template was last reconciled from Meta (ISO)."}},"required":["id","name","language","category","status","quality_score","components","status_changed_at","last_synced_at"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"example":{"data":[{"id":"234567890123456","name":"promo_colchones_sept","language":"es","category":"MARKETING","status":"APPROVED","quality_score":"GREEN","components":null,"status_changed_at":"2026-08-20T10:00:00.000Z","last_synced_at":"2026-08-27T06:00:00.000Z"}],"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp","whatsapp:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Create a WhatsApp message template","description":"Submits a new template for Meta review. Returns immediately with `status: PENDING`. Use `GET /api/v1/whatsapp/templates` to poll the approval status. Body text supports `{{param_name}}` placeholders — when provided, the SDK auto-generates the required `body_text_named_params` example block and sets `parameter_format=NAMED` on the Meta payload.","tags":["WhatsApp Templates"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"name":{"type":"string","minLength":1,"maxLength":512,"description":"Lowercase letters, digits and underscores only."},"category":{"type":"string","enum":["AUTHENTICATION","MARKETING","UTILITY"]},"language":{"type":"string","minLength":2,"maxLength":10,"description":"BCP-47 (e.g. 'en_US', 'es')."},"body_text":{"type":"string","minLength":1,"maxLength":1024,"description":"`{{param_name}}` placeholders auto-generate a NAMED `example` block."},"header_text":{"type":"string","minLength":1,"maxLength":60},"footer_text":{"type":"string","minLength":1,"maxLength":60},"buttons":{"type":"array","items":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["QUICK_REPLY"]},"text":{"type":"string","minLength":1,"maxLength":25}},"required":["type","text"]},{"type":"object","properties":{"type":{"type":"string","enum":["URL"]},"text":{"type":"string","minLength":1,"maxLength":25},"url":{"type":"string","maxLength":2000,"format":"uri","description":"May end in a `{{1}}` dynamic suffix filled in at send time."},"example":{"type":"string","maxLength":2000,"format":"uri","description":"Full sample URL. Required when `url` ends in a `{{1}}` dynamic suffix, must be absent otherwise. {{2}}+ and named params are not supported in button URLs."}},"required":["type","text","url"]},{"type":"object","properties":{"type":{"type":"string","enum":["PHONE_NUMBER"]},"text":{"type":"string","minLength":1,"maxLength":25},"phone_number":{"type":"string","minLength":5,"maxLength":20}},"required":["type","text","phone_number"]},{"type":"object","properties":{"type":{"type":"string","enum":["FLOW"]},"text":{"type":"string","minLength":1,"maxLength":25},"flow_id":{"type":"string","description":"Meta flow id (numeric string). The out-of-window flow path: the template's button opens this flow."},"navigate_screen":{"type":"string","description":"First screen the flow opens on (navigate flows)."},"flow_action":{"type":"string","enum":["navigate","data_exchange"]}},"required":["type","text","flow_id"]}]},"maxItems":10,"description":"Template buttons. Meta caps: 10 total, at most 2 URL, 1 PHONE_NUMBER and 1 FLOW. Buttons are static by default; a URL button may end in a `{{1}}` dynamic suffix (with a required `example`) filled in at send time. Send-time flow params (flow_token, flow_action_data) ride the template send as a sub_type='flow' button component."}},"required":["connection_id","name","category","language","body_text"]}}}},"responses":{"201":{"description":"Template submitted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string"}},"required":["id","status"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta template create failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/templates/sync":{"post":{"summary":"Sync WhatsApp message templates from Meta","description":"Reconciles the mirrored template cache from Meta: follows cursor pagination (no 100-row cap), upserts new/changed templates, removes templates deleted at Meta, and records each status transition. Returns the reconciled list plus a `summary` of `{ upserted, deleted, statusChanges }`.","tags":["WhatsApp Templates"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"}},"required":["connection_id"]}}}},"responses":{"200":{"description":"Reconciled templates + sync summary","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":["string","null"],"description":"Meta template id."},"name":{"type":"string"},"language":{"type":"string"},"category":{"type":["string","null"]},"status":{"type":"string","description":"One of APPROVED, PENDING, REJECTED, PAUSED, DISABLED, IN_APPEAL, LIMIT_EXCEEDED, PENDING_DELETION, DELETED, ARCHIVED."},"quality_score":{"type":["string","null"],"description":"Meta per-template quality: GREEN | YELLOW | RED | UNKNOWN."},"components":{"type":["array","null"],"items":{"type":"object","additionalProperties":{}}},"status_changed_at":{"type":["string","null"],"description":"When the status last transitioned (ISO)."},"last_synced_at":{"type":["string","null"],"description":"When this template was last reconciled from Meta (ISO)."}},"required":["id","name","language","category","status","quality_score","components","status_changed_at","last_synced_at"]}},"meta":{"allOf":[{"$ref":"#/components/schemas/Meta"},{"type":"object","properties":{"summary":{"type":"object","properties":{"upserted":{"type":"integer"},"deleted":{"type":"integer"},"statusChanges":{"type":"integer"}},"required":["upserted","deleted","statusChanges"]}},"required":["summary"]}]}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta template sync failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/templates/{name}":{"delete":{"summary":"Delete a WhatsApp message template by name","description":"Deletes the template at Meta. Cannot be undone.","tags":["WhatsApp Templates"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Template name. Must match `^[a-z0-9_]+$`."},"required":true,"description":"Template name. Must match `^[a-z0-9_]+$`.","name":"name","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"204":{"description":"Template deleted"},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Invalid template name","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta template delete failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/flows":{"get":{"summary":"List WhatsApp flows","description":"Lists ALL flows on the connection's WABA (live Meta read, cursor pagination followed server-side). Includes drafts — filter on `status='PUBLISHED'` before offering flows for sending, or send drafts with `mode='draft'` on `POST /api/v1/messages`.","tags":["WhatsApp Flows"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"Flows list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WhatsAppFlowSummary"}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"example":{"data":[{"id":"1234567890123456","name":"Agenda tu cita","status":"PUBLISHED","categories":["APPOINTMENT_BOOKING"]}],"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta flow list failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Create a WhatsApp flow","description":"Creates a flow in DRAFT. Upload its JSON with `POST /api/v1/whatsapp/flows/{flowId}/assets` (or start from a copy via `clone_flow_id`), then publish. Flows can also be authored in Meta's Flow Builder — this API and the Builder edit the same objects.","tags":["WhatsApp Flows"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"name":{"type":"string","minLength":1,"maxLength":200},"categories":{"type":"array","items":{"type":"string","enum":["SIGN_UP","SIGN_IN","APPOINTMENT_BOOKING","LEAD_GENERATION","CONTACT_US","CUSTOMER_SUPPORT","SURVEY","OTHER"]},"minItems":1},"clone_flow_id":{"type":"string","description":"Copy an existing flow's JSON into the new DRAFT."},"endpoint_uri":{"type":"string","description":"HTTPS data-exchange endpoint (data_api flows only)."}},"required":["connection_id","name","categories"]}}}},"responses":{"201":{"description":"Flow created (DRAFT)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta flow create failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/flows/{flowId}":{"get":{"summary":"Read a WhatsApp flow","description":"Reads one flow including `validation_errors`, JSON/data-API versions and a web `preview` URL (requested with `invalidate(false)` so existing preview links keep working).","tags":["WhatsApp Flows"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Meta flow id (numeric string)."},"required":true,"description":"Meta flow id (numeric string).","name":"flowId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"Flow detail","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WhatsAppFlowDetail"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Invalid flow id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta flow read failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Update a WhatsApp flow's metadata","description":"Updates `name`, `categories` and/or `endpoint_uri` (at least one required). The flow's JSON is updated via the assets endpoint, not here. POST mirrors the Graph verb 1:1.","tags":["WhatsApp Flows"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Meta flow id (numeric string)."},"required":true,"description":"Meta flow id (numeric string).","name":"flowId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"name":{"type":"string","minLength":1,"maxLength":200},"categories":{"type":"array","items":{"type":"string","enum":["SIGN_UP","SIGN_IN","APPOINTMENT_BOOKING","LEAD_GENERATION","CONTACT_US","CUSTOMER_SUPPORT","SURVEY","OTHER"]},"minItems":1},"endpoint_uri":{"type":"string","description":"HTTPS data-exchange endpoint."}},"required":["connection_id"]}}}},"responses":{"200":{"description":"Metadata updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta flow update failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"delete":{"summary":"Delete a WhatsApp flow (DRAFT only)","description":"Deletes a DRAFT flow at Meta. Graph rejects the call for any other status — deprecate published flows instead.","tags":["WhatsApp Flows"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Meta flow id (numeric string)."},"required":true,"description":"Meta flow id (numeric string).","name":"flowId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"204":{"description":"Flow deleted"},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Invalid flow id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta flow delete failed (e.g. flow is not a DRAFT)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/flows/{flowId}/assets":{"get":{"summary":"List a WhatsApp flow's assets","description":"Lists the flow's assets — today exactly one, the flow.json (`asset_type='FLOW_JSON'`), whose `download_url` serves the current JSON.","tags":["WhatsApp Flows"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Meta flow id (numeric string)."},"required":true,"description":"Meta flow id (numeric string).","name":"flowId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"Assets list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WhatsAppFlowAsset"}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Invalid flow id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta assets list failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Upload a WhatsApp flow's JSON","description":"Replaces the flow's JSON. Send it as a JSON object or a pre-serialized string — the server builds Meta's multipart upload either way, so the API surface stays JSON-only. Returns Meta's `validation_errors` verbatim: an upload of semantically broken flow JSON SUCCEEDS with findings, and publishing is rejected until they're fixed.","tags":["WhatsApp Flows"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Meta flow id (numeric string)."},"required":true,"description":"Meta flow id (numeric string).","name":"flowId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"flow_json":{"anyOf":[{"type":"object","additionalProperties":{}},{"type":"string"}],"description":"The flow.json contents — an object, or a string that parses as JSON."}},"required":["connection_id","flow_json"]}}}},"responses":{"200":{"description":"Upload accepted (check validation_errors)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"success":{"type":"boolean"},"validation_errors":{"type":"array","items":{"type":"object","additionalProperties":{}},"description":"Meta's flow.json validation findings, forwarded verbatim. Empty when valid."}},"required":["success","validation_errors"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error (invalid flow id or flow_json not JSON)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta asset upload failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/flows/{flowId}/publish":{"post":{"summary":"Publish a WhatsApp flow","description":"DRAFT → PUBLISHED. Meta rejects flows with outstanding `validation_errors`. Once published, a flow's JSON is immutable — clone it to iterate.","tags":["WhatsApp Flows"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Meta flow id (numeric string)."},"required":true,"description":"Meta flow id (numeric string).","name":"flowId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"}},"required":["connection_id"]}}}},"responses":{"200":{"description":"Flow published","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Invalid flow id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta publish failed (e.g. validation errors outstanding)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/flows/{flowId}/deprecate":{"post":{"summary":"Deprecate a WhatsApp flow","description":"PUBLISHED → DEPRECATED. Terminal — a deprecated flow cannot be re-published; sends referencing it fail.","tags":["WhatsApp Flows"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Meta flow id (numeric string)."},"required":true,"description":"Meta flow id (numeric string).","name":"flowId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"}},"required":["connection_id"]}}}},"responses":{"200":{"description":"Flow deprecated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Invalid flow id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta deprecate failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/account-health":{"get":{"summary":"Get WhatsApp channel health","description":"Cache-backed channel health for a connected WhatsApp channel — whether it can send (`canSend` from Meta's `health_status`), the phone number's quality rating / messaging-limit tier / display-name status / account mode, whether the access token is still valid, whether Atribu's webhook subscription is attached, and a severity-ranked list of `issues[]` (each carrying Meta's `possible_solution` as `remediation`). Serves the persisted snapshot with `refreshedAt` + a `stale` flag, revalidating against Meta when that snapshot is older than the 6h TTL (throttled to one live probe per channel per 30 min; on a failed probe the last-good snapshot is served with `stale:true`, never a 500). Pass `refresh=true` (or read a never-refreshed account) to force a live re-read + persist regardless. An expired token surfaces as `tokenValid:false` + a critical issue, never a 500; any Meta field that is unavailable degrades to null.","tags":["WhatsApp Health"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Force a live Meta re-read + persist. Default false (serve cache)."},"required":false,"description":"Force a live Meta re-read + persist. Default false (serve cache).","name":"refresh","in":"query"}],"responses":{"200":{"description":"Channel health","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WhatsAppChannelHealth"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"example":{"data":{"connectionId":"6a1e2f3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b","wabaId":"987654321098765","phoneNumberId":"123456789012345","canSend":"AVAILABLE","tokenValid":true,"webhookSubscribed":true,"phone":{"displayPhoneNumber":"+56 9 1234 5678","verifiedName":"Riqueza Digital","qualityRating":"GREEN","messagingLimitTier":"TIER_10K","nameStatus":"APPROVED","accountMode":"LIVE","codeVerificationStatus":"VERIFIED","throughputLevel":"STANDARD"},"issues":[],"refreshedAt":"2026-08-28T00:00:00.000Z","stale":false,"reconnectRequired":false,"reconnectUrl":null},"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready (no WhatsApp account row / missing token)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/calling":{"get":{"summary":"Read a number's calling settings","description":"Proxies `GET /{phone-number-id}/settings` and returns Meta's `calling` object verbatim. `connection_id` is required — it is the token resolver's key, and a `phone_number_id` alone resolves nothing. The number is cross-validated against the connection's WABA first. This route NEVER requests `include_sip_credentials`, so SIP credentials are not returned here and are not stored anywhere: the drift snapshot Atribu keeps is an allowlist projection of the reachable settings (calling status, SIP peer, codecs, webhook delivery), so a credential field cannot reach storage even if Meta widens the payload.","tags":["WhatsApp Calling"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"},{"schema":{"type":"string","minLength":1,"maxLength":64},"required":true,"name":"phone_number_id","in":"query"}],"responses":{"200":{"description":"Calling settings","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"calling":{"$ref":"#/components/schemas/WhatsAppCallingSettings"}},"required":["calling"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"example":{"data":{"calling":{"status":"ENABLED","sip":{"servers":[]}}},"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"400":{"description":"phone_number_id is not on this connection's WABA","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready (no WhatsApp account row / missing token)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Enable or update a number's calling settings","description":"Proxies `POST /{phone-number-id}/settings` with the `settings` object verbatim, then reads back what Meta actually holds and returns THAT (Meta normalizes and fills defaults, so echoing the request would report settings that were never applied). An ongoing per-number setting, not an onboarding step. Retried at most twice on transient upstream failures (429, 408, 5xx and network errors) — enabling calling is idempotent, unlike the OTP steps, which are never retried because an attempt there counts against Meta's cap.","tags":["WhatsApp Calling"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WhatsAppCallingBody"}}}},"responses":{"200":{"description":"Applied settings, read back from Meta","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"calling":{"$ref":"#/components/schemas/WhatsAppCallingSettings"}},"required":["calling"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"400":{"description":"phone_number_id is not on this connection's WABA","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready (no WhatsApp account row / missing token)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/calling/sip-credentials":{"get":{"summary":"Read a number's SIP digest credentials","description":"Returns the Meta-generated SIP credentials for one number, so they can be configured on the SIP server that will answer Meta's INVITEs. **This is the only endpoint that returns a secret.** It is a separate route rather than a flag on `GET /whatsapp/calling` on purpose: that route's guarantee that it never requests `include_sip_credentials` is then a property of the code, not of an argument value. Meta publishes no static SIP egress IP ranges and does not support mTLS, so digest auth is the practical control on who may INVITE your trunk: your server answers with `407` and Meta re-sends carrying the challenge response. `username` is the digest username — the business number as DIGITS ONLY, with NO leading `+`. Meta's guide calls it \"the (normalized) business phone number\", which reads as E.164, but a captured call shows Meta authenticating as `username=\"16065177691\"` while its own request URI carries `sip:+16065177691@...`. It is not the `phone_number_id` and not the punctuated `display_phone_number`. Configure any other spelling and every call is rejected with a 401 that presents as ringing which never answers. The response is an allowlist projection of named fields, never Meta's payload, and it is `Cache-Control: no-store`. Atribu stores none of it: the drift snapshot is written by the settings route and structurally cannot hold a credential. Returns 404 when the number has no SIP credential (calling disabled, or no `sip` configuration).","tags":["WhatsApp Calling"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"},{"schema":{"type":"string","minLength":1,"maxLength":64},"required":true,"name":"phone_number_id","in":"query"}],"responses":{"200":{"description":"SIP digest credentials","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"username":{"type":"string","description":"Digest username: the business number as digits, NO leading `+` — e.g. `16065177691`. Verified against a live Meta call."},"servers":{"type":"array","items":{"$ref":"#/components/schemas/WhatsAppSipCredential"}}},"required":["username","servers"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"400":{"description":"phone_number_id is not on this connection's WABA","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found, or the number has no SIP credential (calling disabled / no `sip` configuration)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection not ready (no WhatsApp account row / missing token)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Meta returned no display_phone_number, so the digest username cannot be determined","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/broadcasts":{"get":{"summary":"List broadcasts","description":"Returns up to 50 most-recent broadcasts for the connection, newest first.","tags":["WhatsApp Broadcasts"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"Broadcasts list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"]},"template_name":{"type":"string"},"template_language":{"type":"string"},"status":{"type":"string","enum":["draft","sending","completed","cancelled","failed"]},"total_recipients":{"type":"integer"},"sent_count":{"type":"integer"},"delivered_count":{"type":"integer"},"read_count":{"type":"integer"},"failed_count":{"type":"integer"},"started_at":{"type":["string","null"]},"completed_at":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","name","template_name","template_language","status","total_recipients","sent_count","delivered_count","read_count","failed_count","started_at","completed_at","created_at"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"example":{"data":[{"id":"a3b4c5d6-e7f8-4a9b-0c1d-2e3f4a5b6c7d","name":"Promo colchones — Sept","template_name":"promo_colchones_sept","template_language":"es","status":"completed","total_recipients":500,"sent_count":500,"delivered_count":480,"read_count":310,"failed_count":20,"started_at":"2026-08-25T13:00:00.000Z","completed_at":"2026-08-25T13:12:00.000Z","created_at":"2026-08-25T12:55:00.000Z"}],"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}}},"x-atribu-scopes":["whatsapp","whatsapp:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Create a broadcast","description":"Creates a `draft` broadcast row + recipient rows. Does NOT start sending — call `POST /api/v1/whatsapp/broadcasts/{id}/send` to dispatch. Recipients are capped at 1,000 per broadcast (matches the internal UI cap).","tags":["WhatsApp Broadcasts"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"template_name":{"type":"string","minLength":1,"maxLength":512},"template_language":{"type":"string","minLength":2,"maxLength":10},"template_components":{"type":"array","items":{"type":"object","additionalProperties":{}}},"recipients":{"type":"array","items":{"type":"object","properties":{"phone_number":{"type":"string","minLength":1,"maxLength":32},"template_params":{"type":"object","additionalProperties":{}}},"required":["phone_number"]},"minItems":1,"maxItems":1000},"name":{"type":"string","minLength":1,"maxLength":200}},"required":["connection_id","template_name","template_language","recipients"]}}}},"responses":{"201":{"description":"Broadcast created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"]},"template_name":{"type":"string"},"template_language":{"type":"string"},"status":{"type":"string","enum":["draft","sending","completed","cancelled","failed"]},"total_recipients":{"type":"integer"},"sent_count":{"type":"integer"},"delivered_count":{"type":"integer"},"read_count":{"type":"integer"},"failed_count":{"type":"integer"},"started_at":{"type":["string","null"]},"completed_at":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","name","template_name","template_language","status","total_recipients","sent_count","delivered_count","read_count","failed_count","started_at","completed_at","created_at"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/broadcasts/{id}":{"get":{"summary":"Get a broadcast + recipients","description":"Returns the broadcast row plus up to 200 recipients with delivery state.","tags":["WhatsApp Broadcasts"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Broadcast detail","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"]},"template_name":{"type":"string"},"template_language":{"type":"string"},"status":{"type":"string","enum":["draft","sending","completed","cancelled","failed"]},"total_recipients":{"type":"integer"},"sent_count":{"type":"integer"},"delivered_count":{"type":"integer"},"read_count":{"type":"integer"},"failed_count":{"type":"integer"},"started_at":{"type":["string","null"]},"completed_at":{"type":["string","null"]},"created_at":{"type":"string"},"recipients":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"phone_number":{"type":"string"},"template_params":{"type":["object","null"],"additionalProperties":{}},"status":{"type":"string","enum":["pending","sent","delivered","read","failed","cancelled"]},"wamid":{"type":["string","null"]},"error_message":{"type":["string","null"]},"error_reason_code":{"type":["string","null"],"description":"Stable machine-readable reason code from the Meta classifier (e.g. 'meta_131050' = recipient stopped marketing). Useful for de-duping permanently-failed recipients on the next broadcast create."},"sent_at":{"type":["string","null"]}},"required":["id","phone_number","template_params","status","wamid","error_message","error_reason_code","sent_at"]}}},"required":["id","name","template_name","template_language","status","total_recipients","sent_count","delivered_count","read_count","failed_count","started_at","completed_at","created_at","recipients"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"404":{"description":"Broadcast not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp","whatsapp:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"patch":{"summary":"Cancel a broadcast","description":"Marks an in-flight broadcast as `cancelled`. Recipients not yet sent stay `pending` permanently. Already-sent messages are NOT recalled.","tags":["WhatsApp Broadcasts"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["cancelled"]}},"required":["status"]}}}},"responses":{"200":{"description":"Broadcast cancelled","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"]},"template_name":{"type":"string"},"template_language":{"type":"string"},"status":{"type":"string","enum":["draft","sending","completed","cancelled","failed"]},"total_recipients":{"type":"integer"},"sent_count":{"type":"integer"},"delivered_count":{"type":"integer"},"read_count":{"type":"integer"},"failed_count":{"type":"integer"},"started_at":{"type":["string","null"]},"completed_at":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","name","template_name","template_language","status","total_recipients","sent_count","delivered_count","read_count","failed_count","started_at","completed_at","created_at"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"404":{"description":"Broadcast not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Broadcast is not in a cancellable status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/broadcasts/{id}/send":{"post":{"summary":"Start sending a broadcast","description":"Transitions the broadcast to `sending` and iterates recipients with a 100ms inter-send pacing. Per-recipient sends call the WhatsApp Cloud API; failures mark the recipient `failed` without aborting the run. **Long-running**: this endpoint blocks until all recipients have been attempted. For broadcasts over a few hundred recipients prefer to leave the connection open longer than your client's default timeout.","tags":["WhatsApp Broadcasts"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Broadcast send completed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"]},"template_name":{"type":"string"},"template_language":{"type":"string"},"status":{"type":"string","enum":["draft","sending","completed","cancelled","failed"]},"total_recipients":{"type":"integer"},"sent_count":{"type":"integer"},"delivered_count":{"type":"integer"},"read_count":{"type":"integer"},"failed_count":{"type":"integer"},"started_at":{"type":["string","null"]},"completed_at":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","name","template_name","template_language","status","total_recipients","sent_count","delivered_count","read_count","failed_count","started_at","completed_at","created_at"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"404":{"description":"Broadcast not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Broadcast is not in a sendable status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/otp-capture":{"post":{"summary":"Publish an OTP captured off Meta's verification call","description":"AUTHENTICATED BY CLIENT CREDENTIALS (`client_secret_basic`), not an API key: this runs before the OAuth exchange has minted one. Embedded Signup v4 has no phone-screen bypass, so Meta may verify a number in-popup by placing a VOICE call that reads six digits aloud. A consumer that captured that call publishes the result here, keyed by the connect's OAuth `state`; the dealer's connect page — which is behind Meta's popup and cannot otherwise learn the code — subscribes and shows it. Held for a few minutes only: this is a live registration secret, it is never logged, and it expires without anybody sweeping it. 503 when the relay store is unavailable (never a silent success: the consumer must know the dealer will see nothing).","tags":["WhatsApp Registration"],"security":[{"clientSecretBasic":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"state":{"type":"string","minLength":8,"maxLength":256,"description":"The consumer's OAuth `state` for this connect — the correlation id both sides already share."},"code":{"type":["string","null"],"pattern":"^\\d{6}$","description":"The six digits, ONLY when the capture cleared the consumer's confidence gate. Null otherwise."},"candidate_code":{"type":["string","null"],"pattern":"^\\d{6}$","description":"Best guess even when it was not trusted — shown as a hint, never auto-used."},"confidence":{"type":"number","minimum":0,"maximum":1,"description":"Combined transcription x parse confidence, in [0,1]."},"reads":{"type":"integer","minimum":0,"maximum":8,"description":"How many times the code was read back (Meta reads it twice)."},"uncertain_positions":{"type":"array","items":{"type":"integer","minimum":0,"maximum":5},"maxItems":6,"description":"0-indexed digit positions the reads disagreed on, so the UI can flag exactly those."},"audio_url":{"type":["string","null"],"format":"uri","description":"Short-TTL signed URL for the captured audio, scoped to this capture session. Relayed, never fetched here."},"audio_expires_at":{"type":["string","null"],"format":"date-time","description":"When `audio_url` stops working, so a UI can retire ▶ rather than fail on it."}},"required":["state","confidence"]}}}},"responses":{"200":{"description":"Capture held for the connect page","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"published":{"type":"boolean"},"expires_in_seconds":{"type":"number"}},"required":["published","expires_in_seconds"]},"meta":{"$ref":"#/components/schemas/ClientCredentialsMeta"}},"required":["data","meta"]}}}},"400":{"description":"Malformed JSON body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Client authentication failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"`invalid_state` — the state is not a live WhatsApp Embedded Signup connect belonging to this client. The state is a correlation id, not a capability, so publishing under one requires proving it is your own in-flight connect of the right kind.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limit exceeded for this client","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"Relay store unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["app_credential"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"},"get":{"summary":"Read back a published OTP capture","description":"AUTHENTICATED BY CLIENT CREDENTIALS (`client_secret_basic`), like the publish. Polls the relay for a capture published under this connect's `state`. `capture: null` is the NORMAL answer for most of a connect's life (the dealer is still working through Meta's screens and no call has landed) — a 200 with nothing, not a 404. Entries are bound to the consumer app that published them.","tags":["WhatsApp Registration"],"security":[{"clientSecretBasic":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"state","in":"query"}],"responses":{"200":{"description":"The capture, or null when none has landed yet","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"capture":{"type":["object","null"],"properties":{"code":{"type":["string","null"],"description":"The six digits — ONLY when the capture cleared the publisher's confidence gate. Null otherwise."},"candidate_code":{"type":["string","null"],"description":"Best guess even when it was not trusted. A hint for the dealer, never auto-used."},"confidence":{"type":"number","description":"Combined transcription x parse confidence in [0,1]."},"reads":{"type":"number","description":"How many times the code was read back. Meta reads it twice."},"uncertain_positions":{"type":"array","items":{"type":"number"},"description":"0-indexed digit positions the two reads disagreed on, so the UI can flag exactly those."},"audio_url":{"type":["string","null"],"description":"Short-TTL signed URL for the captured audio, scoped to this capture session. Never public, never logged."},"audio_expires_at":{"type":["string","null"]},"captured_at":{"type":"string"}},"required":["code","candidate_code","confidence","reads","uncertain_positions","audio_url","audio_expires_at","captured_at"]}},"required":["capture"]},"meta":{"$ref":"#/components/schemas/ClientCredentialsMeta"}},"required":["data","meta"]}}}},"400":{"description":"state is required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Client authentication failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limit exceeded for this client","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"Relay store unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["app_credential"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"}},"/api/v1/whatsapp/registration/phone-numbers":{"post":{"summary":"Add (or migrate) a phone number onto a WABA","description":"Proxies `POST /{waba-id}/phone_numbers`. Set `migrate: true` to move an already-registered number onto this WABA in one call (bring-your-own). Returns the new `phone_number_id` to drive the request_code → verify_code → register flow. IDEMPOTENT: if the number is already on this WABA, resolves and returns its existing `phone_number_id` with `already_present: true` instead of failing.","tags":["WhatsApp Registration"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"cc":{"type":"string","minLength":1,"maxLength":4,"description":"Country calling code without '+' (e.g. '56' for Chile)."},"phone_number":{"type":"string","minLength":1,"maxLength":20,"description":"Local subscriber number, digits only (no country code)."},"verified_name":{"type":"string","minLength":1,"maxLength":120,"description":"Display name shown to customers — the verified_name badge."},"migrate":{"type":"boolean","description":"When true, MIGRATE an already-registered number onto this WABA in one call (Graph `migrate_phone_number:true`). Resolves the 'already on another WABA' conflict and preserves quality rating / messaging limits / OBA status. Precondition (caller's responsibility): 2SV disabled on the source number. Still requires the full request_code → verify_code → register flow afterwards."}},"required":["connection_id","cc","phone_number","verified_name"]}}}},"responses":{"201":{"description":"Phone number added (or already present, resolved idempotently)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"phone_number_id":{"type":"string"},"migrated":{"type":"boolean"},"already_present":{"type":"boolean","description":"True when the number was already on this WABA and its id was resolved without a new add/migrate."}},"required":["phone_number_id","migrated","already_present"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"402":{"description":"WABA has no payment method (Meta 131042) — migrate needs funding","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta add-number failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"get":{"summary":"List the phone numbers on a WABA","description":"Proxies `GET /{waba-id}/phone_numbers`. Detects whether a number is ALREADY on the WABA (already-connected onboarding, only_waba_sharing ES where Meta never returns the phone on the connection) and resolves its `phone_number_id` before registration.","tags":["WhatsApp Registration"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"WABA phone numbers","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"phone_number_id — the id every send/registration call keys on."},"display_phone_number":{"type":"string","description":"E.164, formatted by Meta (e.g. \"+56 2 2914 5100\")."},"verified_name":{"type":"string"},"quality_rating":{"type":"string"},"messaging_limit":{"type":"string"},"status":{"type":"string","description":"Cloud API state. CONNECTED = registered + live (skip OTP); PENDING/UNVERIFIED = on the WABA but not yet registered."},"code_verification_status":{"type":"string","description":"VERIFIED once the number has cleared OTP."}},"required":["id","display_phone_number","verified_name","quality_rating"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"example":{"data":[{"id":"123456789012345","display_phone_number":"+56 9 1234 5678","verified_name":"Riqueza Digital","quality_rating":"GREEN","messaging_limit":"TIER_10K","status":"CONNECTED","code_verification_status":"VERIFIED"}],"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta phone_numbers read failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/registration/request-code":{"post":{"summary":"Request the registration OTP (voice)","description":"Proxies `POST /{phone-number-id}/request_code` with `code_method=VOICE`. Single, non-idempotent action — NEVER auto-retried. Meta's retry/lockout window is undocumented; do not re-call without operator intent.","tags":["WhatsApp Registration"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"phone_number_id":{"type":"string","minLength":1,"maxLength":64,"description":"The phone_number_id returned by add-number."},"language":{"type":"string","minLength":2,"maxLength":10,"description":"Meta OTP language code (e.g. 'en', 'es'). Delivered by VOICE."}},"required":["connection_id","phone_number_id","language"]}}}},"responses":{"200":{"description":"OTP requested","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"requested":{"type":"boolean"},"code_method":{"type":"string","enum":["VOICE"]}},"required":["requested","code_method"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta request_code failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/registration/verify-code":{"post":{"summary":"Verify the registration OTP","description":"Proxies `POST /{phone-number-id}/verify_code`. Attempt-limited by Meta — NEVER auto-retried (the OTP is consumed on each try).","tags":["WhatsApp Registration"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"phone_number_id":{"type":"string","minLength":1,"maxLength":64},"code":{"type":"string","minLength":1,"maxLength":12,"description":"The OTP the dealer received by voice call."}},"required":["connection_id","phone_number_id","code"]}}}},"responses":{"200":{"description":"OTP verified","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"verified":{"type":"boolean"}},"required":["verified"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta verify_code failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/registration/register":{"post":{"summary":"Register the number + subscribe Atribu's app","description":"Proxies `POST /{phone-number-id}/register` (messaging_product + pin) THEN `POST /{waba-id}/subscribed_apps`. Idempotent. `/register` is capped 10/number/72h — Meta error `133016` surfaces as `whatsapp_register_limit` (429), propagated verbatim and never retried server-side.","tags":["WhatsApp Registration"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"phone_number_id":{"type":"string","minLength":1,"maxLength":64},"pin":{"type":"string","minLength":6,"maxLength":6,"description":"6-digit two-step-verification PIN to (re)establish 2SV."}},"required":["connection_id","phone_number_id","pin"]}}}},"responses":{"200":{"description":"Registered + subscribed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"registered":{"type":"boolean"},"subscribed":{"type":"boolean"}},"required":["registered","subscribed"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"402":{"description":"WABA has no payment method (Meta 131042)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Registration capped 10/number/72h (Meta 133016)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta register failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/registration/subscribe":{"post":{"summary":"Subscribe Atribu's app to an already-registered WABA","description":"Proxies `POST /{waba-id}/subscribed_apps` only — no add-number, no OTP, no register. For a bring-your-own WABA that is already registered/CONNECTED (case 4a share). Idempotent; Atribu subscribes only its OWN app.","tags":["WhatsApp Registration"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"}},"required":["connection_id"]}}}},"responses":{"200":{"description":"Subscribed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"subscribed":{"type":"boolean"}},"required":["subscribed"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta subscribe failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/registration/subscribed-apps":{"get":{"summary":"List apps subscribed to a WABA","description":"Proxies `GET /{waba-id}/subscribed_apps` for incumbent detection (case 4b): who currently holds the WABA (e.g. Chatwoot) + the ≤2-partner cap, before assigning Atribu.","tags":["WhatsApp Registration"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"Subscribed apps","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":["string","null"],"description":"Subscribed app id (normalized from flat or nested Graph shape)."},"name":{"type":["string","null"]},"link":{"type":["string","null"]},"is_self":{"type":"boolean","description":"TRUE when this entry is Atribu's own Meta app. Atribu is the only party that knows its own app id with certainty, so it answers here instead of every consumer keeping a copy — a copy silently goes wrong the moment Atribu's app differs between environments, and being wrong either mutes the customer's AI (our app read as an incumbent) or double-replies (an incumbent read as us). ABSENT — never `false` — when Atribu cannot determine its own id: an all-false list is indistinguishable from 'Atribu is not subscribed', which is the same catastrophic misread this field exists to prevent. Treat absent as 'ask someone else', not as 'no'."}},"required":["id","name","link"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta subscribed_apps read failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/whatsapp/registration/funding":{"get":{"summary":"Read a WABA's funding + status (payment gate)","description":"Proxies `GET /{waba-id}?fields=primary_funding_id,currency,status`. `primary_funding_id` empty-state is undocumented — returned raw (absent field = no payment method attached).","tags":["WhatsApp Registration"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"Funding info","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"primary_funding_id":{"type":"string","description":"Meta credit-line id. ABSENT when no payment method is attached (undocumented empty-state — returned raw)."},"currency":{"type":"string"},"status":{"type":"string"}}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing scope or unauthorized connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta funding read failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/triggers":{"get":{"summary":"List comment-to-DM triggers","description":"Returns every trigger for the Instagram account behind the supplied `connection_id`, oldest first. Triggers fire on inbound comments where the comment text matches `keyword` per `keyword_match_mode`; the matching commenter receives `opening_message` as a DM and (optionally) `public_comment_reply` as a public reply.","tags":["Instagram Triggers"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"Triggers list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"connection_id":{"type":"string","format":"uuid"},"keyword":{"type":"string"},"keyword_match_mode":{"type":"string","enum":["contains","exact","regex"]},"case_sensitive":{"type":"boolean"},"post_id_allowlist":{"type":["array","null"],"items":{"type":"string"}},"opening_message":{"type":"string"},"public_comment_reply":{"type":["string","null"]},"agent_context_hint":{"type":["string","null"]},"enabled":{"type":"boolean"},"trigger_count":{"type":"integer"},"last_triggered_at":{"type":["string","null"]},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","connection_id","keyword","keyword_match_mode","case_sensitive","post_id_allowlist","opening_message","public_comment_reply","agent_context_hint","enabled","trigger_count","last_triggered_at","created_at","updated_at"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"example":{"data":[{"id":"f6a7b8c9-d0e1-4f2a-3b4c-5d6e7f8a9b0c","connection_id":"6a1e2f3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b","keyword":"precio","keyword_match_mode":"contains","case_sensitive":false,"post_id_allowlist":null,"opening_message":"¡Hola! Te cuento los precios por acá 😊","public_comment_reply":"Te escribimos por DM 📩","agent_context_hint":null,"enabled":true,"trigger_count":214,"last_triggered_at":"2026-08-28T02:00:00.000Z","created_at":"2026-06-01T10:00:00.000Z","updated_at":"2026-06-01T10:00:00.000Z"}],"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Create a comment-to-DM trigger","description":"Inserts a new trigger row. Each (ig_account, keyword) is unique — duplicates 409.","tags":["Instagram Triggers"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"keyword":{"type":"string","minLength":1,"maxLength":500},"keyword_match_mode":{"type":"string","enum":["contains","exact","regex"],"default":"contains"},"case_sensitive":{"type":"boolean","default":false},"post_id_allowlist":{"type":["array","null"],"items":{"type":"string","minLength":1,"maxLength":64},"maxItems":200},"opening_message":{"type":"string","minLength":1,"maxLength":2000},"public_comment_reply":{"type":["string","null"],"maxLength":2000},"agent_context_hint":{"type":["string","null"],"maxLength":1000},"enabled":{"type":"boolean","default":true}},"required":["connection_id","keyword","opening_message"]}}}},"responses":{"201":{"description":"Trigger created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"connection_id":{"type":"string","format":"uuid"},"keyword":{"type":"string"},"keyword_match_mode":{"type":"string","enum":["contains","exact","regex"]},"case_sensitive":{"type":"boolean"},"post_id_allowlist":{"type":["array","null"],"items":{"type":"string"}},"opening_message":{"type":"string"},"public_comment_reply":{"type":["string","null"]},"agent_context_hint":{"type":["string","null"]},"enabled":{"type":"boolean"},"trigger_count":{"type":"integer"},"last_triggered_at":{"type":["string","null"]},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","connection_id","keyword","keyword_match_mode","case_sensitive","post_id_allowlist","opening_message","public_comment_reply","agent_context_hint","enabled","trigger_count","last_triggered_at","created_at","updated_at"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"409":{"description":"A trigger with this keyword already exists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/triggers/{id}":{"patch":{"summary":"Update a comment-to-DM trigger","description":"Partial update. Any field from the create body is acceptable.","tags":["Instagram Triggers"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"keyword":{"type":"string","minLength":1,"maxLength":500},"keyword_match_mode":{"type":"string","enum":["contains","exact","regex"],"default":"contains"},"case_sensitive":{"type":"boolean","default":false},"post_id_allowlist":{"type":["array","null"],"items":{"type":"string","minLength":1,"maxLength":64},"maxItems":200},"opening_message":{"type":"string","minLength":1,"maxLength":2000},"public_comment_reply":{"type":["string","null"],"maxLength":2000},"agent_context_hint":{"type":["string","null"],"maxLength":1000},"enabled":{"type":"boolean","default":true}}}}}},"responses":{"200":{"description":"Trigger updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"connection_id":{"type":"string","format":"uuid"},"keyword":{"type":"string"},"keyword_match_mode":{"type":"string","enum":["contains","exact","regex"]},"case_sensitive":{"type":"boolean"},"post_id_allowlist":{"type":["array","null"],"items":{"type":"string"}},"opening_message":{"type":"string"},"public_comment_reply":{"type":["string","null"]},"agent_context_hint":{"type":["string","null"]},"enabled":{"type":"boolean"},"trigger_count":{"type":"integer"},"last_triggered_at":{"type":["string","null"]},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","connection_id","keyword","keyword_match_mode","case_sensitive","post_id_allowlist","opening_message","public_comment_reply","agent_context_hint","enabled","trigger_count","last_triggered_at","created_at","updated_at"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"404":{"description":"Trigger not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"delete":{"summary":"Delete a comment-to-DM trigger","description":"Hard-deletes the trigger row. Existing claim ledger rows expire naturally.","tags":["Instagram Triggers"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Trigger deleted"},"404":{"description":"Trigger not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/triggers/{id}/test-dm":{"post":{"summary":"Send the trigger's opening_message as a DM to a test IGSID","description":"Uses the HUMAN_AGENT message tag — recipient must have DMed the IG account within the past 7 days, otherwise Meta rejects the send. Intended for QA without polluting the comment trigger's `trigger_count`.","tags":["Instagram Triggers"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"recipient_igsid":{"type":"string","pattern":"^\\d+$"}},"required":["recipient_igsid"]}}}},"responses":{"200":{"description":"Test DM sent","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"provider_message_id":{"type":"string"}},"required":["provider_message_id"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"404":{"description":"Trigger not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Meta rejected the send (recipient outside 7-day window)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/triggers/resume":{"post":{"summary":"Manually clear a tripped comment-to-DM circuit","description":"Clears the per-account `comment_to_dm_paused_until` and the Redis tripped flag, re-enabling trigger fires. The breaker normally clears itself once spam-pattern signals subside; this endpoint exists for ops intervention.","tags":["Instagram Triggers"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"}},"required":["connection_id"]}}}},"responses":{"200":{"description":"Circuit cleared","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"resumed":{"type":"boolean"}},"required":["resumed"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/contacts/{igsid}":{"get":{"summary":"Resolve an Instagram contact's profile","description":"Resolves an IGSID (the sender id Meta puts on inbound DM/comment webhooks) to that person's public profile — name, `@username`, avatar, follower count, and the mutual-follow flags — for the Instagram account behind the supplied `connection_id`. Backs an inbox so it can show `@jane` + an avatar instead of a bare numeric id. Requires the connection's messaging permission (`instagram_manage_messages` for fb_login, `instagram_business_manage_messages` for ig_login) and an active conversation with the user. Returns 404 when the profile can't be resolved (user blocked DMs, invalid IGSID, missing permission) — fall back to the raw IGSID.","tags":["Instagram"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Instagram-scoped user id from a webhook event."},"required":true,"description":"Instagram-scoped user id from a webhook event.","name":"igsid","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"Contact profile","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"igsid":{"type":"string","description":"The Instagram-scoped user id that was looked up."},"name":{"type":["string","null"],"description":"Display name, if visible."},"username":{"type":["string","null"],"description":"@handle, if visible."},"profile_pic":{"type":["string","null"],"description":"Avatar URL (short-lived; re-fetch as needed)."},"is_verified_user":{"type":["boolean","null"]},"follower_count":{"type":["number","null"]},"is_user_follow_business":{"type":["boolean","null"],"description":"Whether this user follows the connected account."},"is_business_follow_user":{"type":["boolean","null"],"description":"Whether the connected account follows this user."}},"required":["igsid","name","username","profile_pic","is_verified_user","follower_count","is_user_follow_business","is_business_follow_user"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"example":{"data":{"igsid":"17841400000000000","name":"Camila Rojas","username":"camila.rojas","profile_pic":"https://scontent.cdninstagram.com/v/avatar.jpg","is_verified_user":false,"follower_count":412,"is_user_follow_business":true,"is_business_follow_user":false},"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"404":{"description":"Profile could not be resolved","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/media":{"get":{"summary":"List the connected Instagram account's media","description":"Live proxy of `GET /{ig-user-id}/media` — newest first, with Meta's own cursor forwarded as `pagination.cursor` (pass it back as `after`). Not served from any Atribu mirror: a post published seconds ago is present, an edited caption is current, a deleted post is gone. Requires the `instagram` scope and (for OAuth-flow keys) an active authorization for the connection.","tags":["Instagram Media"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Meta page size. Default 25."},"required":false,"description":"Meta page size. Default 25.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Cursor from a previous response's `pagination.cursor`."},"required":false,"description":"Cursor from a previous response's `pagination.cursor`.","name":"after","in":"query"}],"responses":{"200":{"description":"Media page","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/InstagramMedia"}},"pagination":{"$ref":"#/components/schemas/Pagination"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","pagination","meta"]},"example":{"data":[{"id":"18000000000000000","caption":"Nuevo modelo disponible 🛏","media_url":"https://scontent.cdninstagram.com/v/media.jpg","permalink":"https://www.instagram.com/p/CxAmple/","timestamp":"2026-08-20T15:00:00+00:00","media_type":"IMAGE","thumbnail_url":null,"children":null}],"pagination":{"has_next":false},"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"403":{"description":"Missing `instagram` scope or OAuth-app not authorized for this connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection unhealthy, tokenless, or without an IG business account id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta media list failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Create an Instagram media container (publish step 1)","description":"Live proxy of `POST /{ig-user-id}/media`. Creates a **container** — nothing appears on the account until `POST /api/v1/instagram/media/publish` runs with the returned `container_id`. Meta's two-step model is preserved deliberately: a carousel is one container per child (`media_type: 'IMAGE'` + `is_carousel_item: true`), then one `media_type: 'CAROUSEL'` container listing those ids in `children`, then one publish. `image_url` must be a public HTTPS URL that Meta downloads server-side. Video / Reels / Stories containers are not proxied yet. Requires `instagram_business_content_publish` (ig_login) / `instagram_content_publish` (fb_login) on the connection.","tags":["Instagram Media"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"oneOf":[{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"media_type":{"type":"string","enum":["IMAGE"]},"image_url":{"type":"string","format":"uri","description":"Public HTTPS JPEG URL Meta downloads."},"caption":{"type":"string","maxLength":2200},"is_carousel_item":{"type":"boolean","description":"Set true when this container will be a carousel child."}},"required":["connection_id","media_type","image_url"]},{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"media_type":{"type":"string","enum":["CAROUSEL"]},"children":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":10,"description":"Container ids created with `is_carousel_item: true`, in display order."},"caption":{"type":"string","maxLength":2200}},"required":["connection_id","media_type","children"]}]}}}},"responses":{"201":{"description":"Container created (not yet live)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"container_id":{"type":"string","description":"Pass as `creation_id` to POST /api/v1/instagram/media/publish."},"media_type":{"type":"string","enum":["IMAGE","CAROUSEL"]}},"required":["container_id","media_type"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing `instagram` scope or OAuth-app not authorized for this connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection unhealthy, tokenless, or without an IG business account id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error, or Meta refused the container (unreachable image_url, bad aspect ratio)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta container create failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/media/{media_id}":{"get":{"summary":"Read one Instagram media object","description":"Live proxy of `GET /{ig-media-id}` with the carousel `children` edge expanded. Same fields as the list endpoint. Meta's `media_url` / `thumbnail_url` are short-lived CDN links — fetch them on demand rather than storing them.","tags":["Instagram Media"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Instagram media id (numeric string)."},"required":true,"description":"Instagram media id (numeric string).","name":"media_id","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"}],"responses":{"200":{"description":"Media object","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/InstagramMedia"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing `instagram` scope or OAuth-app not authorized for this connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta media read failed (e.g. media id not on this account)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/media/publish":{"post":{"summary":"Publish an Instagram media container (publish step 2)","description":"Live proxy of `POST /{ig-user-id}/media_publish`. Makes the container live and returns the published `media_id`, readable via `GET /api/v1/instagram/media/{media_id}`. Atribu adds no polling, retry or scheduling — a container Meta has not finished processing surfaces as a classified provider error for the caller to retry on its own terms. Meta caps publishing at 25 posts per 24h per account; exceeding it comes back as a rate-limit error.","tags":["Instagram Media"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string","format":"uuid"},"creation_id":{"type":"string","description":"The `container_id` from POST /api/v1/instagram/media."}},"required":["connection_id","creation_id"]}}}},"responses":{"201":{"description":"Media published","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"media_id":{"type":"string","description":"The now-live Instagram media id."},"creation_id":{"type":"string"}},"required":["media_id","creation_id"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"403":{"description":"Missing `instagram` scope or OAuth-app not authorized for this connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection unhealthy, tokenless, or without an IG business account id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error, or Meta refused the publish","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Meta's 25-posts-per-24h publishing limit reached","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta publish failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/conversations":{"get":{"summary":"List the connected Instagram account's conversations","description":"Live proxy of `GET /{node-id}/conversations?platform=instagram` — one page of the account's inbox, with Meta's own cursor forwarded as `pagination.cursor` (pass it back as `after`). Pass `user_id` (an IGSID) to get just the thread with that person, Meta's documented single-thread filter. Not served from any Atribu mirror. **Two Meta-side limits apply and cannot be overridden:** conversations sitting in the Requests folder with no activity for 30+ days are not returned at all (there is no folder parameter, and no way to recover them once they have aged out), and `updated_time` is a last-activity stamp rather than a creation time. Requires the `instagram` scope, the connection's messaging permission (`instagram_business_manage_messages` for ig_login, `instagram_manage_messages` + `pages_manage_metadata` for fb_login), and (for OAuth-flow keys) an active authorization for the connection.","tags":["Instagram Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"},{"schema":{"type":"string","description":"IGSID — narrows the response to the single conversation with that person."},"required":false,"description":"IGSID — narrows the response to the single conversation with that person.","name":"user_id","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Meta page size. Default 25."},"required":false,"description":"Meta page size. Default 25.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Cursor from a previous response's `pagination.cursor`."},"required":false,"description":"Cursor from a previous response's `pagination.cursor`.","name":"after","in":"query"}],"responses":{"200":{"description":"Conversation page","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/InstagramConversation"}},"pagination":{"$ref":"#/components/schemas/Pagination"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","pagination","meta"]},"example":{"data":[{"id":"aWdfZAG0000000000000000000000000","updated_time":"2026-08-28T02:00:00.000Z","participants":[{"id":"17841400000000000","username":"camila.rojas"}]}],"pagination":{"has_next":false},"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"403":{"description":"Missing `instagram` scope or OAuth-app not authorized for this connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection unhealthy, tokenless, or missing the node the conversations edge hangs off (page_id for fb_login, ig_business_account_id for ig_login)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error, or Meta refused the read","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Meta's 2-calls-per-second-per-account messaging limit reached","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta conversations list failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/instagram/conversations/{conversation_id}/messages":{"get":{"summary":"Read the messages in an Instagram conversation","description":"Live proxy of `GET /{conversation-id}/messages` — one page of a thread's messages, newest first, with the full content field set (`message`, `from`, `to`, `attachments`, `shares`, `reactions`) requested in the same call, so reading a whole inbox costs roughly one call per conversation rather than one per message. **Meta serves content for the 20 most recent messages of a thread only.** Older messages still enumerate, but Meta withholds their body: they arrive with `message: null` and null content fields, carrying just `id` + `created_time`. That is passed through exactly as Meta returned it — Atribu does not fail the page and does not substitute a placeholder record, because deciding what a truncated transcript means is the consumer's business logic. A conversation id that is not on this account surfaces as a 422 with the Meta code intact, not an opaque 502.","tags":["Instagram Conversations"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Conversation id from GET /api/v1/instagram/conversations."},"required":true,"description":"Conversation id from GET /api/v1/instagram/conversations.","name":"conversation_id","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"connection_id","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Meta page size. Default 25."},"required":false,"description":"Meta page size. Default 25.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Cursor from a previous response's `pagination.cursor`."},"required":false,"description":"Cursor from a previous response's `pagination.cursor`.","name":"after","in":"query"}],"responses":{"200":{"description":"Message page","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/InstagramConversationMessage"}},"pagination":{"$ref":"#/components/schemas/Pagination"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","pagination","meta"]}}}},"403":{"description":"Missing `instagram` scope or OAuth-app not authorized for this connection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Connection not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Connection unhealthy or tokenless","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error, or Meta could not resolve the conversation id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Meta's 2-calls-per-second-per-account messaging limit reached","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Upstream Meta conversation messages read failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["instagram"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/webhooks/subscriptions":{"get":{"summary":"List webhook subscriptions","description":"Returns the subscriptions this key owns for the calling profile. An OAuth-flow-minted key sees its own app's; a workspace's own `atb_live_` key sees the workspace's self-serve subscriptions (#1049). A workspace that has never created one gets an empty list, not a 403.","tags":["Webhooks"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"Subscriptions list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"app_id":{"type":"string","format":"uuid"},"profile_id":{"type":"string","format":"uuid"},"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string"}},"providers":{"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["active","paused","failed"]},"last_delivery_at":{"type":["string","null"]},"consecutive_failures":{"type":"number"},"previous_secret_expires_at":{"type":["string","null"]},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","app_id","profile_id","url","events","providers","status","last_delivery_at","consecutive_failures","previous_secret_expires_at","created_at","updated_at"]}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]},"example":{"data":[{"id":"e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b","app_id":"f2a3b4c5-d6e7-4f8a-9b0c-1d2e3f4a5b6c","profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb","url":"https://example-crm.app/webhooks/atribu","events":["conversion.created"],"providers":["meta_ads"],"status":"active","last_delivery_at":"2026-08-28T02:00:00.000Z","consecutive_failures":0,"previous_secret_expires_at":null,"created_at":"2026-07-01T09:00:00.000Z","updated_at":"2026-07-01T09:00:00.000Z"}],"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}}},"x-atribu-scopes":[],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Create a webhook subscription","description":"Creates a webhook subscription. The HMAC `secret` is returned ONCE in the response body and never again — persist it on your side. Atribu signs every outbound event as `X-Atribu-Signature: t=<unix>,v1=<hex_hmac_sha256>` over `<ts>.<body>`.","tags":["Webhooks"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri","description":"HTTPS URL where Atribu will POST signed events. Must be a public DNS name (#1150): no literal IPs, no internal namespaces (.internal/.local/.ts.net/…), no embedded credentials, and every resolved address must be publicly routable — loopback, RFC 1918, link-local (169.254.169.254), CGNAT 100.64/10, unique-local (fd00:ec2::254), multicast and their IPv6-mapped forms are refused with 422. Re-checked before every delivery; redirects are not followed."},"events":{"type":"array","items":{"type":"string","enum":["message.received","message.delivery","conversation.started","calendar.event.changed","message.echo","message.history","contacts.sync","template.updated","channel.health.updated","call.status.updated","call.permission.updated","comment.received","catalog.updated","product.stock.changed","product.price.changed","checkout.abandoned","order.status.changed","connection.connected","connection.reconnect_required","connection.revoked","handoff.completed","recompute.completed","conversion.attributed","export.completed","export.failed","profile.freshness.changed"]}},"providers":{"type":"array","items":{"type":"string","enum":["whatsapp","instagram","email","google_calendar","shopify","atribu"]},"description":"Channel providers, plus `atribu` for the platform lifecycle events (connection.*, handoff.completed, recompute.completed, conversion.attributed, export.*, profile.freshness.changed). Delivery matches on providers AND events, so those nine are opt-in: without `atribu` here you receive none of them."}},"required":["url","events","providers"]}}}},"responses":{"201":{"description":"Subscription created; `secret` field shown once","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"app_id":{"type":"string","format":"uuid"},"profile_id":{"type":"string","format":"uuid"},"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string"}},"providers":{"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["active","paused","failed"]},"last_delivery_at":{"type":["string","null"]},"consecutive_failures":{"type":"number"},"previous_secret_expires_at":{"type":["string","null"]},"created_at":{"type":"string"},"updated_at":{"type":"string"},"secret":{"type":"string"}},"required":["id","app_id","profile_id","url","events","providers","status","last_delivery_at","consecutive_failures","previous_secret_expires_at","created_at","updated_at","secret"]}},"required":["data"]}}}},"409":{"description":"A subscription with this URL already exists for this (app, profile)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Body failed validation, or the url is not a public https target (#1150)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/webhooks/subscriptions/{id}":{"patch":{"summary":"Update a webhook subscription","description":"Update `url`, `events`, `providers`, or `status` (active/paused). At least one field is required.","tags":["Webhooks"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string"}},"providers":{"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["active","paused"]}}}}}},"responses":{"200":{"description":"Subscription updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"app_id":{"type":"string","format":"uuid"},"profile_id":{"type":"string","format":"uuid"},"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string"}},"providers":{"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["active","paused","failed"]},"last_delivery_at":{"type":["string","null"]},"consecutive_failures":{"type":"number"},"previous_secret_expires_at":{"type":["string","null"]},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","app_id","profile_id","url","events","providers","status","last_delivery_at","consecutive_failures","previous_secret_expires_at","created_at","updated_at"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"404":{"description":"Subscription not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Body failed validation, or the url is not a public https target (#1150)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"delete":{"summary":"Delete a webhook subscription","description":"Hard-deletes the subscription. Existing `webhook_deliveries` audit rows cascade via FK ON DELETE CASCADE.","tags":["Webhooks"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Subscription deleted"},"404":{"description":"Subscription not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/webhooks/subscriptions/{id}/rotate-secret":{"post":{"summary":"Rotate a subscription's HMAC secret","description":"Generates a fresh HMAC secret and atomically rolls current→previous with a grace window (default 7d, max 90d). **Important: deploy dual-verify on your side BEFORE calling this** — Atribu starts signing with the new secret immediately. The previous secret stays in DB only so subscribers can dual-verify during their deploy.","tags":["Webhooks"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"grace_days":{"type":"integer","minimum":0,"maximum":90}}}}}},"responses":{"200":{"description":"New secret returned once","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"subscription_id":{"type":"string","format":"uuid"},"secret":{"type":"string"},"grace_days":{"type":"number"},"previous_secret_expires_at":{"type":"string"}},"required":["subscription_id","secret","grace_days","previous_secret_expires_at"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":[],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/webhooks/test/{id}":{"post":{"summary":"Fire a synthetic test event","description":"Enqueues a synthetic event onto the outbound queue for the subscription, matching the subscription's FIRST provider: `message.received` for whatsapp/instagram/email/shopify, `calendar.event.changed` for google_calendar, `recompute.completed` for `atribu` (the platform-lifecycle provider, which has no `message.received`). Subscriber receives a fully-signed POST so signature + payload handling can be verified before the first real event.","tags":["Webhooks"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Synthetic event enqueued","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"enqueued":{"type":"boolean"},"event_id":{"type":"string","format":"uuid"},"subscription_id":{"type":"string","format":"uuid"}},"required":["enqueued","event_id","subscription_id"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":[],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/webhooks/deliveries/{id}/replay":{"post":{"summary":"Replay a webhook delivery","description":"Re-enqueues the payload from an existing `webhook_deliveries` row. The original `event.id` is preserved so subscribers can de-dup the replay. Only the OAuth app that owns the subscription can replay.","tags":["Webhooks"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Replay queued","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"enqueued":{"type":"boolean"},"delivery_id":{"type":"string","format":"uuid"},"event_id":{"type":"string","format":"uuid"},"subscription_id":{"type":"string","format":"uuid"}},"required":["enqueued","delivery_id","event_id","subscription_id"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"404":{"description":"Delivery or subscription not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/oauth/token":{"post":{"summary":"OAuth 2.0 token endpoint","description":"Mints an Atribu API key. Two grant types:\n\n**`authorization_code`** (RFC 6749 §4.1.3) — exchanges a single-use `code` issued at `/oauth/authorize` consent. Requires `code` + `redirect_uri`.\n\n**`client_credentials`** (RFC 6749 §4.4) — mints a delegated per-profile key with no user interaction. Requires `profile_id`. Succeeds **only** when an active (non-revoked) `oauth_app_authorizations` grant already exists for (client, profile); it never creates one, so it cannot be used to acquire access, only to mint a token for access already granted. A profile with no grant answers `invalid_grant`, deliberately indistinguishable from a profile that does not exist.\n\nEvery call mints **new** key material and does not rotate: previously issued keys stay valid until explicitly revoked at `/oauth/revoke`. At most **5** active keys may exist per grant — the 6th call returns `invalid_request` telling you to revoke one first.\n\nRevoking the underlying authorization (the connected-apps page in the Atribu UI) revokes **every** key minted under it, `client_credentials` keys included.\n\nAccepts both `application/x-www-form-urlencoded` (spec-default) and `application/json` bodies. Client authentication (both grants): `client_secret_basic` (`Authorization: Basic <base64(id:secret)>`) OR `client_secret_post` (`client_id` + `client_secret` in body).\n\nReturns `Cache-Control: no-store` per RFC.","tags":["OAuth"],"security":[{"clientSecretBasic":[]}],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"grant_type":{"type":"string","enum":["authorization_code","client_credentials"],"description":"`authorization_code` exchanges a consent code. `client_credentials` mints a delegated per-profile key server-to-server for a profile this client already holds an active grant on — no user interaction."},"code":{"type":"string","description":"`authorization_code` only."},"redirect_uri":{"type":"string","format":"uri","description":"`authorization_code` only. Must match the value sent to `/oauth/authorize`."},"profile_id":{"type":"string","format":"uuid","description":"`client_credentials` only. The Profile whose data is being delegated. The client must already hold an active authorization for it."},"client_id":{"type":"string","description":"Required when not using `Authorization: Basic`."},"client_secret":{"type":"string","description":"Required when not using `Authorization: Basic`."},"code_verifier":{"type":"string","description":"PKCE verifier (RFC 7636). Required when the authorize step sent a `code_challenge`."}},"required":["grant_type"]}},"application/json":{"schema":{"type":"object","properties":{"grant_type":{"type":"string","enum":["authorization_code","client_credentials"],"description":"`authorization_code` exchanges a consent code. `client_credentials` mints a delegated per-profile key server-to-server for a profile this client already holds an active grant on — no user interaction."},"code":{"type":"string","description":"`authorization_code` only."},"redirect_uri":{"type":"string","format":"uri","description":"`authorization_code` only. Must match the value sent to `/oauth/authorize`."},"profile_id":{"type":"string","format":"uuid","description":"`client_credentials` only. The Profile whose data is being delegated. The client must already hold an active authorization for it."},"client_id":{"type":"string","description":"Required when not using `Authorization: Basic`."},"client_secret":{"type":"string","description":"Required when not using `Authorization: Basic`."},"code_verifier":{"type":"string","description":"PKCE verifier (RFC 7636). Required when the authorize step sent a `code_challenge`."}},"required":["grant_type"]}}}},"responses":{"200":{"description":"Key minted","content":{"application/json":{"schema":{"type":"object","properties":{"access_token":{"type":"string","description":"The minted Atribu API key (`atb_live_...`). Use as `Authorization: Bearer <token>` on `/api/v1/*` calls."},"token_type":{"type":"string","enum":["bearer"]},"scope":{"type":"string","description":"Space-separated API-key scopes, derived from the connect scope that was granted. `analytics` → `analytics:read campaigns:read conversions:read realtime:read`; `analytics_pii` adds `customers:read visitors:read`; `attribution_write` → `goals:write attribution:write tracking:write reports:write`."},"connection_id":{"type":"string","format":"uuid","description":"UUID of the `data_connections` row the user authorized. Pass on `POST /api/v1/messages`. Absent for profile-level (analytics) grants, which are not scoped to a connection."},"profile_id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"}},"required":["access_token","token_type","scope","profile_id","workspace_id"]},"example":{"access_token":"atb_live_9f1c2b3a4d5e6f708192a3b4c5d6e7f8","token_type":"bearer","scope":"analytics:read campaigns:read conversions:read realtime:read","connection_id":null,"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb","workspace_id":"3c9b1e2a-6f4d-4a8b-9c1e-2a6f4d4a8b9c"}}}},"400":{"description":"`invalid_grant` (spent/unknown code, or no active authorization for `profile_id`) / `invalid_request` (missing parameter, or the 5-active-key cap) / `unsupported_grant_type`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"RFC 6749 §5.2 / RFC 7009 §2.2.1 error code."},"error_description":{"type":"string"}},"required":["error"],"description":"RFC-compliant OAuth error envelope. Distinct from the `/api/v1/*` `ErrorResponse` shape."}}}},"401":{"description":"`invalid_client`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"RFC 6749 §5.2 / RFC 7009 §2.2.1 error code."},"error_description":{"type":"string"}},"required":["error"],"description":"RFC-compliant OAuth error envelope. Distinct from the `/api/v1/*` `ErrorResponse` shape."}}}},"500":{"description":"`server_error`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"RFC 6749 §5.2 / RFC 7009 §2.2.1 error code."},"error_description":{"type":"string"}},"required":["error"],"description":"RFC-compliant OAuth error envelope. Distinct from the `/api/v1/*` `ErrorResponse` shape."}}}}},"x-atribu-scopes":[],"x-atribu-auth":["app_credential"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"}},"/oauth/revoke":{"post":{"summary":"RFC 7009 token revocation","description":"Revokes an access token minted via `/oauth/token`. Returns 200 regardless of whether the token existed (RFC §2.2 — no enumeration). Idempotent. Client authentication identical to `/oauth/token`. The `token_type_hint` is accepted (`access_token`) but ignored — only `access_token` is supported.","tags":["OAuth"],"security":[{"clientSecretBasic":[]}],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"token":{"type":"string"},"token_type_hint":{"type":"string","enum":["access_token"]},"client_id":{"type":"string"},"client_secret":{"type":"string"}},"required":["token"]}},"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string"},"token_type_hint":{"type":"string","enum":["access_token"]},"client_id":{"type":"string"},"client_secret":{"type":"string"}},"required":["token"]}}}},"responses":{"200":{"description":"Empty 200 (RFC 7009)"},"400":{"description":"`invalid_request`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"RFC 6749 §5.2 / RFC 7009 §2.2.1 error code."},"error_description":{"type":"string"}},"required":["error"],"description":"RFC-compliant OAuth error envelope. Distinct from the `/api/v1/*` `ErrorResponse` shape."}}}},"401":{"description":"`invalid_client`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"RFC 6749 §5.2 / RFC 7009 §2.2.1 error code."},"error_description":{"type":"string"}},"required":["error"],"description":"RFC-compliant OAuth error envelope. Distinct from the `/api/v1/*` `ErrorResponse` shape."}}}}},"x-atribu-scopes":[],"x-atribu-auth":["app_credential"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"}},"/api/v1/admin/oauth-apps":{"post":{"summary":"Create a consumer OAuth app","description":"Creates a new consumer OAuth app (e.g. \"vitrina\"). Generates fresh `client_secret` + `jwt_signing_secret` server-side — both returned **once** in the 201 response and never readable again. Store them safely; rotate via the dedicated endpoints.","tags":["Admin"],"security":[{"adminAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"client_id":{"type":"string","minLength":3,"maxLength":64,"pattern":"^[a-z0-9_-]+$/i"},"name":{"type":"string","minLength":1,"maxLength":128},"description":{"type":"string","maxLength":2000},"logo_url":{"type":"string","format":"uri"},"redirect_uris":{"type":"array","items":{"type":"string","format":"uri"},"minItems":1,"maxItems":20},"allowed_scopes":{"type":"array","items":{"type":"string","enum":["whatsapp","instagram"]},"minItems":1,"maxItems":2},"created_by":{"type":"string","format":"uuid"}},"required":["client_id","name","redirect_uris","allowed_scopes","created_by"]}}}},"responses":{"201":{"description":"App created; secrets shown once","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"client_id":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"logo_url":{"type":["string","null"]},"redirect_uris":{"type":"array","items":{"type":"string","format":"uri"}},"allowed_scopes":{"type":"array","items":{"type":"string","enum":["whatsapp","instagram"]}},"status":{"type":"string","enum":["active","suspended"]},"created_at":{"type":"string"},"client_secret":{"type":"string"},"jwt_signing_secret":{"type":"string"}},"required":["id","client_id","name","description","logo_url","redirect_uris","allowed_scopes","status","created_at","client_secret","jwt_signing_secret"]}},"required":["data"]}}}},"400":{"description":"Invalid request (e.g. unknown `created_by` user)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"`client_id` already exists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["admin_secret"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"}},"/api/v1/admin/oauth-apps/{id}":{"patch":{"summary":"Update a consumer OAuth app","description":"Update name, description, logo, redirect URIs, scopes, or status. At least one field is required.","tags":["Admin"],"security":[{"adminAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"description":{"type":["string","null"],"maxLength":2000},"logo_url":{"type":["string","null"],"format":"uri"},"redirect_uris":{"type":"array","items":{"type":"string","format":"uri"},"minItems":1,"maxItems":20},"allowed_scopes":{"type":"array","items":{"type":"string","enum":["whatsapp","instagram"]},"minItems":1,"maxItems":2},"status":{"type":"string","enum":["active","suspended"]}}}}}},"responses":{"200":{"description":"App updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"client_id":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"logo_url":{"type":["string","null"]},"redirect_uris":{"type":"array","items":{"type":"string","format":"uri"}},"allowed_scopes":{"type":"array","items":{"type":"string","enum":["whatsapp","instagram"]}},"status":{"type":"string","enum":["active","suspended"]},"created_at":{"type":"string"}},"required":["id","client_id","name","description","logo_url","redirect_uris","allowed_scopes","status","created_at"]}},"required":["data"]}}}},"404":{"description":"App not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["admin_secret"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"},"delete":{"summary":"Suspend a consumer OAuth app (kill-switch)","description":"Flips `status='suspended'` AND atomically revokes every `api_key` minted via this app. Idempotent — re-running on an already-suspended app returns `keys_revoked=0`. After suspension, in-flight consumer keys stop working immediately; the app reappears (with the same id) only if re-activated via PATCH.","tags":["Admin"],"security":[{"adminAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"App suspended; api_keys cascaded","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["suspended"]},"keys_revoked":{"type":"integer"}},"required":["id","status","keys_revoked"]}},"required":["data"]}}}},"404":{"description":"App not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["admin_secret"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"}},"/api/v1/admin/oauth-apps/{id}/rotate-client-secret":{"post":{"summary":"Rotate the OAuth app's `client_secret`","description":"Atomically rolls `client_secret_hash` → `previous_client_secret_hash` with a grace window (default 7 days, max 90). The new plaintext secret is returned **once**. In-flight `/oauth/token` calls authenticated with the old secret continue to succeed until the grace window expires.","tags":["Admin"],"security":[{"adminAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"grace_days":{"type":"integer","minimum":0,"maximum":90}}}}}},"responses":{"200":{"description":"New secret returned once","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"oauth_app_id":{"type":"string","format":"uuid"},"client_secret":{"type":"string"},"grace_days":{"type":"integer"},"previous_client_secret_expires_at":{"type":"string"}},"required":["oauth_app_id","client_secret","grace_days","previous_client_secret_expires_at"]}},"required":["data"]}}}},"404":{"description":"App not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`grace_days` out of range","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["admin_secret"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"}},"/api/v1/admin/oauth-apps/{id}/rotate-jwt-secret":{"post":{"summary":"Rotate the OAuth app's `id_token_hint` HS256 signing secret","description":"Atomically rolls `jwt_signing_secret` → `previous_jwt_signing_secret` with a grace window (default 7 days, max 90). Atribu's `id_token_hint` verifier accepts both during the window so in-flight authorize flows don't break mid-deploy.","tags":["Admin"],"security":[{"adminAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"grace_days":{"type":"integer","minimum":0,"maximum":90}}}}}},"responses":{"200":{"description":"New secret returned once","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"oauth_app_id":{"type":"string","format":"uuid"},"jwt_signing_secret":{"type":"string"},"grace_days":{"type":"integer"},"previous_jwt_signing_secret_expires_at":{"type":"string"}},"required":["oauth_app_id","jwt_signing_secret","grace_days","previous_jwt_signing_secret_expires_at"]}},"required":["data"]}}}},"404":{"description":"App not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`grace_days` out of range","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["admin_secret"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"}},"/api/v1/admin/oauth-apps/{id}/usage":{"get":{"summary":"One consumer app's API usage","description":"What a registered consumer app did across **every tenant it holds a grant on**, over a date range: totals, a per-route breakdown and a per-day series, each with request counts, the 4xx/5xx split and p50/p95 latency (#452, Phase 4).\n\nOps-grain, so it deliberately has no tenant filter — a workspace-narrowed version of this read is a different route (`GET /api/v1/workspaces/{workspaceId}/api-usage`), gated on a session bearer.\n\n`totals` reports counts only. Percentiles do not sum, so there is no cross-route p95 — read `by_route` or `by_day`, each of which is computed over its own rows.\n\n`api_request_log` is pruned at 90 days, so the window is capped at 92 and a wider range is refused rather than silently clamped.","tags":["Admin"],"security":[{"adminAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","example":"2026-08-01"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-09-01"},"required":true,"name":"date_to","in":"query"}],"responses":{"200":{"description":"Usage for the window","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"oauth_app_id":{"type":"string","format":"uuid"},"client_id":{"type":"string","example":"vitrina"},"name":{"type":"string","example":"Vitrina"},"rate_tier":{"type":"string","description":"The api_rate_tiers key this app's per-minute allowance comes from.","example":"standard"},"date_from":{"type":"string"},"date_to":{"type":"string"},"totals":{"type":"object","properties":{"requests":{"type":"integer"},"errors_4xx":{"type":"integer"},"errors_5xx":{"type":"integer"},"error_rate":{"type":["number","null"],"description":"(4xx + 5xx) / requests, or null when the window holds no requests — never 0, which would read as a perfect window."}},"required":["requests","errors_4xx","errors_5xx","error_rate"]},"by_route":{"type":"array","items":{"type":"object","properties":{"route_id":{"type":"string","example":"overview"},"method":{"type":"string","example":"GET"},"requests":{"type":"integer"},"errors_4xx":{"type":"integer"},"errors_5xx":{"type":"integer"},"p50_latency_ms":{"type":"integer"},"p95_latency_ms":{"type":"integer"}},"required":["route_id","method","requests","errors_4xx","errors_5xx","p50_latency_ms","p95_latency_ms"]}},"by_day":{"type":"array","items":{"type":"object","properties":{"day":{"type":"string","example":"2026-08-14"},"requests":{"type":"integer"},"errors_4xx":{"type":"integer"},"errors_5xx":{"type":"integer"},"p50_latency_ms":{"type":"integer"},"p95_latency_ms":{"type":"integer"}},"required":["day","requests","errors_4xx","errors_5xx","p50_latency_ms","p95_latency_ms"]}}},"required":["oauth_app_id","client_id","name","rate_tier","date_from","date_to","totals","by_route","by_day"]}},"required":["data"]}}}},"400":{"description":"Missing/malformed date_from or date_to, or a window wider than 92 days","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"App not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["admin_secret"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"}},"/api/v1/admin/pii-grants":{"get":{"summary":"Who can read personal data through this API","description":"Every live OAuth grant carrying the PII overlay (`analytics_pii`) and every live API key carrying `customers:read` / `visitors:read`, with the workspace, the profile and the date it was issued (#452, Phase 4).\n\n**Access, not accesses.** This answers who HOLDS the capability; `GET /api/v1/workspaces/{workspaceId}/pii-access-log` answers who exercised it and when. A compliance question usually needs both.\n\nOps-grain: it spans every tenant, which is why it is gated on the admin secret rather than scoped to a workspace. `key_prefix` is the first 12 characters of a key — never the key, never a hash.","tags":["Admin"],"security":[{"adminAuth":[]}],"responses":{"200":{"description":"Every credential that can currently read PII","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"grants":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string","enum":["authorization","api_key"],"description":"`authorization` — a tenant granted a consumer app the PII overlay. `api_key` — an operator minted a key carrying a PII scope directly."},"id":{"type":"string","format":"uuid"},"app_id":{"type":["string","null"],"format":"uuid"},"app_client_id":{"type":["string","null"]},"app_name":{"type":["string","null"]},"workspace_id":{"type":["string","null"],"format":"uuid"},"workspace_name":{"type":["string","null"]},"profile_id":{"type":["string","null"],"format":"uuid"},"profile_name":{"type":["string","null"]},"scopes":{"type":"array","items":{"type":"string"}},"key_prefix":{"type":["string","null"],"description":"First 12 characters of the key. Null on a grant row."},"issued_at":{"type":"string"}},"required":["kind","id","app_id","app_client_id","app_name","workspace_id","workspace_name","profile_id","profile_name","scopes","key_prefix","issued_at"]}},"counts":{"type":"object","properties":{"total":{"type":"integer"},"authorizations":{"type":"integer"},"api_keys":{"type":"integer"}},"required":["total","authorizations","api_keys"]}},"required":["grants","counts"]}},"required":["data"]}}}}},"x-atribu-scopes":[],"x-atribu-auth":["admin_secret"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"}},"/api/v1/admin/shopify-apps":{"post":{"summary":"Register a merchant's custom-distribution Shopify app","description":"Stores a per-Merchant Shopify app's credentials (the secret sealed at rest under `SHOPIFY_APP_SECRETS_KEY`), binds its stores, and returns one install link per store to forward to the merchant. **No response from this endpoint ever contains the client secret.** The `workspace_id` / `profile_id` you pass IS the binding: every install through these links lands there with no Atribu session involved. Listing more than one `shop_domains` entry without a `plus_org_id` is refused with 422 — Shopify API Terms §2.3.20 forbid making a Custom Application available to more than one Merchant, and the same rule is enforced by a database trigger.","tags":["Admin"],"security":[{"adminAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"client_id":{"type":"string","minLength":3,"maxLength":255},"client_secret":{"type":"string","minLength":8,"maxLength":1024,"description":"Shopify's Client secret. Write-only — never returned."},"shop_domains":{"type":"array","items":{"type":"string","minLength":1,"maxLength":255},"minItems":1,"maxItems":50,"description":"Stores this app serves, in any accepted spelling (`acme`, `acme.myshopify.com`, a full admin URL). Normalized server-side. More than one requires `plus_org_id`.","example":["acme-store.myshopify.com"]},"merchant_label":{"type":"string","minLength":1,"maxLength":200},"plus_org_id":{"type":["string","null"],"minLength":1,"maxLength":200},"workspace_id":{"type":"string","format":"uuid"},"profile_id":{"type":"string","format":"uuid"},"created_by":{"type":"string","format":"uuid","description":"The registering staff user. Required: the install finalizes with no session, so this is the only available actor for its audit event."},"scopes":{"type":"array","items":{"type":"string","pattern":"^[a-z_]+$"},"minItems":1,"maxItems":60,"description":"Optional. Defaults to `SHOPIFY_OAUTH_SCOPES` minus the approval-gated scopes (`read_all_orders`) — the largest set a brand-new custom-distribution app can actually install with. Pass it only when this app's Dev Dashboard version differs."}},"required":["client_id","client_secret","shop_domains","merchant_label","workspace_id","profile_id","created_by"]}}}},"responses":{"201":{"description":"App registered; install links returned","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"app_id":{"type":"string","format":"uuid"},"client_id":{"type":"string"},"kind":{"type":"string","enum":["custom_distribution"]},"merchant_label":{"type":"string"},"plus_org_id":{"type":["string","null"]},"status":{"type":"string"},"scopes":{"type":"array","items":{"type":"string"},"description":"The exact OAuth scopes this app's authorize URL requests. `read_all_orders` is approval-gated PER APP by Shopify, so a freshly registered custom-distribution app defaults to `SHOPIFY_OAUTH_SCOPES` minus the gated scopes — asking for one the app has not been approved for makes Shopify 400 the authorize URL before the merchant sees a consent screen. Widen it with PATCH once Shopify approves the access request."},"pending_scope_approvals":{"type":"array","items":{"type":"string"},"description":"Approval-gated scopes in `SHOPIFY_OAUTH_SCOPES` that this app is NOT requesting yet. Non-empty means an access request is owed at Partner Dashboard → the app → API access → Request access. Empty means nothing to request.","example":["read_all_orders"]},"created_by":{"type":["string","null"],"format":"uuid"},"created_at":{"type":"string"},"shops":{"type":"array","items":{"$ref":"#/components/schemas/ShopifyAppShop"}},"install_urls":{"type":"object","additionalProperties":{"type":"string"},"description":"Shop domain → the install link to forward to that merchant.","example":{"acme-store.myshopify.com":"https://www.atribu.app/install/shopify/8Kx2s0Rk4h1QpVn7yWc3bA6fLzT9dJmXeUo5gHrN2iY"}}},"required":["app_id","client_id","kind","merchant_label","plus_org_id","status","scopes","pending_scope_approvals","created_by","created_at","shops","install_urls"]}},"required":["data"]}}}},"400":{"description":"Unknown `profile_id` / `created_by`, or a profile outside the named workspace","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"`client_id` already registered, or a store already bound to another app","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation error, including the Shopify API Terms §2.3.20 one-Merchant rule","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"`SHOPIFY_APP_SECRETS_KEY` or `NEXT_PUBLIC_APP_URL` is unset on this deploy","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["admin_secret"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"},"get":{"summary":"List registered Shopify apps","description":"Every registered app with its bound stores. Secrets are never included; neither are install tokens.","tags":["Admin"],"security":[{"adminAuth":[]}],"parameters":[{"schema":{"type":"string","enum":["active","revoked"]},"required":false,"name":"status","in":"query"}],"responses":{"200":{"description":"Apps with their bound stores","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/ShopifyApp"},{"type":"object","properties":{"shops":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/ShopifyAppShop"},{"type":"object","properties":{"shopify_app_id":{"type":"string","format":"uuid"},"created_at":{"type":"string"}},"required":["shopify_app_id","created_at"]}]}}},"required":["shops"]}]}}},"required":["data"]},"example":{"data":{"apps":[{"id":"d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f7a","client_id":"shp_client_a1b2c3d4","kind":"custom_distribution","merchant_label":"Riqueza Digital","plus_org_id":null,"status":"active","scopes":["read_orders","read_products"],"created_by":"ops@atribu.app","created_at":"2026-08-12T14:03:00.000Z"}]},"meta":{}}}}},"400":{"description":"Invalid `status`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["admin_secret"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"}},"/api/v1/admin/shopify-apps/{id}":{"patch":{"summary":"Update a Shopify app's OAuth scopes","description":"The \"Shopify approved the access request\" lever. Shopify approves protected scopes (`read_all_orders`) per app, on a rolling basis with no SLA, so a merchant installs with the ungated set and this widens it afterwards. `scopes` is the FULL set, not a delta, and it is the only editable field — client id, secret, merchant label, Plus org and store bindings are identity, changed by DELETE + POST. **The merchant is not prompted when you call this.** Shopify raises the approval prompt when they next open the app, so a quiet store can sit on the old scopes indefinitely. Refused with 422 for a `public` app: the App Store app's set is the `SHOPIFY_OAUTH_SCOPES` env var, and a stored set would silently override it for every App Store install.","tags":["Admin"],"security":[{"adminAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"scopes":{"type":"array","items":{"type":"string","pattern":"^[a-z_]+$"},"minItems":1,"maxItems":60,"description":"The complete scope set this app now requests. Must match the app's Dev Dashboard version, or the install fails at Shopify.","example":["read_orders","read_customers","read_all_orders","write_pixels","read_products","read_checkouts","read_customer_events","read_inventory"]}},"required":["scopes"]}}}},"responses":{"200":{"description":"Scopes updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"client_id":{"type":"string"},"kind":{"type":"string"},"merchant_label":{"type":"string"},"plus_org_id":{"type":["string","null"]},"status":{"type":"string"},"scopes":{"type":"array","items":{"type":"string"}},"previous_scopes":{"type":"array","items":{"type":"string"}},"merchant_reapproval_required":{"type":"boolean","description":"True when the new set adds a scope the app did not previously request. The merchant must approve it in the Shopify admin, prompted on their next app open."}},"required":["id","client_id","kind","merchant_label","plus_org_id","status","scopes","previous_scopes","merchant_reapproval_required"]}},"required":["data"]}}}},"404":{"description":"App not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Malformed scope, or the app is not `custom_distribution`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["admin_secret"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"},"delete":{"summary":"Revoke a Shopify app (kill-switch)","description":"Flips `status='revoked'`. The bound stores are **kept** (`ON DELETE RESTRICT`): deleting the app would silently drop those stores to the public App Store app's credential and fail every webhook delivery with no stated cause. After revocation the shop's credential resolves to nothing rather than to something usable, the OAuth callback refuses to complete, and the install links 404. Up to 60s of an in-process resolver cache may lag in other processes.","tags":["Admin"],"security":[{"adminAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"App revoked; bindings retained","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"client_id":{"type":"string"},"kind":{"type":"string"},"merchant_label":{"type":"string"},"plus_org_id":{"type":["string","null"]},"status":{"type":"string","enum":["revoked"]},"bound_shops":{"type":"array","items":{"type":"string"}}},"required":["id","client_id","kind","merchant_label","plus_org_id","status","bound_shops"]}},"required":["data"]}}}},"404":{"description":"App not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":[],"x-atribu-auth":["admin_secret"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"}},"/api/v1/touches":{"get":{"summary":"List organic touches with attribution credit","description":"Non-ad marketing touches (Instagram comments/DMs, WhatsApp clicks, …) with the revenue Atribu credited to each one. Use it to value organic effort against paid: `credited_revenue` is the same credit-weighted cash the ad endpoints report, just landing on a touch that had no spend behind it.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"2026-03-01"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-03-25"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string","description":"Default 10, max 100.","example":"50"},"required":false,"description":"Default 10, max 100.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Filter to a single organic subtype.","example":"ig_comment"},"required":false,"description":"Filter to a single organic subtype.","name":"subtype","in":"query"}],"responses":{"200":{"description":"Organic touches","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/OrganicTouch"}},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}},"400":{"description":"Missing or malformed date range","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/dm-ads":{"get":{"summary":"Messaging-cohort ad performance with conversation intent","description":"Click-to-message ads ranked by `cost_per_high_intent` (spend ÷ qualified-conversation count) with a Wilson-confidence band, the intent breakdown, and any open recommendations targeting the ad. Conversation excerpts are omitted here — the MCP tool `top_dm_ads` serves the redacted drawer-ready payload. Profile-scoped; the workspace-wide view is MCP-only.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Defaults to 27 days ago.","example":"2026-03-01"},"required":false,"description":"Defaults to 27 days ago.","name":"date_from","in":"query"},{"schema":{"type":"string","description":"Defaults to today.","example":"2026-03-25"},"required":false,"description":"Defaults to today.","name":"date_to","in":"query"},{"schema":{"type":"string","description":"Default 25, max 50.","example":"25"},"required":false,"description":"Default 25, max 50.","name":"limit","in":"query"}],"responses":{"200":{"description":"DM ad performance","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"count":{"type":"number"},"ads":{"type":"array","items":{"$ref":"#/components/schemas/DmAd"}}},"required":["date_from","date_to","count","ads"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed dates or a range over 366 days","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/recommendations":{"get":{"summary":"List open recommendations for this key's profile","description":"The AI media buyer's suggestions — scale a winner, pause an underperformer, reallocate budget, refresh a creative before fatigue lands. Single-profile scope (the key is profile-pinned); the workspace-wide view is the MCP tool `list_workspace_recommendations`. All list params are comma-separated and validated strictly — an unknown value is a 400, never a silent drop.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"Comma-separated. Default `open`.","example":"open,applied"},"required":false,"description":"Comma-separated. Default `open`.","name":"statuses","in":"query"},{"schema":{"type":"string","description":"Comma-separated recommendation kinds.","example":"scale_winner,pause_underperformer"},"required":false,"description":"Comma-separated recommendation kinds.","name":"kinds","in":"query"},{"schema":{"type":"string","description":"Comma-separated: `safe`, `medium`, `manual_only`.","example":"safe"},"required":false,"description":"Comma-separated: `safe`, `medium`, `manual_only`.","name":"risk_tiers","in":"query"},{"schema":{"type":"string","description":"`7d` | `14d` | `28d` | `lifetime`. Default `28d`.","example":"28d"},"required":false,"description":"`7d` | `14d` | `28d` | `lifetime`. Default `28d`.","name":"score_window","in":"query"},{"schema":{"type":"string","description":"Default 20, max 100.","example":"20"},"required":false,"description":"Default 20, max 100.","name":"limit","in":"query"}],"responses":{"200":{"description":"Recommendations","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Recommendation"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string"},"score_window":{"type":"string"},"count":{"type":"number"}},"required":["profile_id","score_window","count"]}},"required":["data","meta"]}}}},"400":{"description":"Unknown filter value","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/recommendations/{id}/apply":{"post":{"summary":"Apply a recommendation (inline, terminal response)","description":"Executes the recommendation through the meta-actions layer: one write spine, one audit trail (`meta_action_log`), one rollback system. There is no queue and nothing to poll — the response says what happened.\n\nRequires an `Idempotency-Key` header (1–256 chars). Leg-level dedup is automatic (the executor derives its keys from the recommendation id), so retrying an already-applied recommendation returns `200` with `replayed: true` rather than double-spending.\n\n`applied_by` is REQUIRED for the budget kinds (`scale_winner`, `budget_reallocate_winners`): money-tier changes never move without a human on the record, and the id is verified to be an active owner/admin of the key's workspace.\n\n**`require_approval: true` routes the apply past a person instead of executing it.** You get `202` with a hand-off — a URL to give your user (session-less, works on a phone, expires in 45 minutes) and an id to poll. That is how an agent, or an agency acting for a client, puts a human between itself and an ad account's money. Every refusal the direct apply gives (`404`, `409`, `410`, `403`) is still answered BEFORE the hand-off is minted, so a hand-off is never handed out for a change that cannot succeed.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"maxLength":256,"description":"Client-generated retry token, unique per logical apply attempt."},"required":true,"description":"Client-generated retry token, unique per logical apply attempt.","name":"Idempotency-Key","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"applied_by":{"type":"string","format":"uuid","description":"`auth.users` id to attribute the apply to. Required for budget kinds."},"require_approval":{"type":"boolean","description":"Ask a human before applying. Nothing is written: the response is `202` with `{status: \"awaiting_approval\", handoff}` — hand `handoff.url` to the person who owns the account and poll `GET /api/v1/handoffs/{id}`. Approving runs this exact call through the same audited path; the hand-off's `result.apply` then carries the same body a direct apply returns. Rejecting settles it `cancelled` and writes nothing.","example":true}}}}}},"responses":{"200":{"description":"Terminal outcome — `applied`, `partial` or `failed`","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"recommendation_id":{"type":"string"},"status":{"type":"string","enum":["applied","partial","failed"]},"replayed":{"type":"boolean"},"legs":{"type":"array","items":{"$ref":"#/components/schemas/RecommendationApplyLeg"}},"external_action_url":{"type":["string","null"]},"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"}},"required":["code","message"],"description":"Present only when `status` is `failed`."}},"required":["recommendation_id","status","replayed","legs","external_action_url"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"202":{"description":"`require_approval: true` — nothing applied; a hand-off is waiting for a human. Poll `GET /api/v1/handoffs/{id}`.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"status":{"type":"string","enum":["awaiting_approval"]},"handoff":{"type":"object","properties":{"id":{"type":"string"},"url":{"type":["string","null"]},"expires_at":{"type":"string"},"status":{"type":"string"},"kind":{"type":"string"}},"required":["id","url","expires_at","status","kind"]}},"required":["status","handoff"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing `Idempotency-Key`, bad id, or malformed body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"`applied_by` is not a workspace admin, or a budget kind was applied with no human on the record","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Recommendation not found for this key's profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Not `open`, or another apply is already running","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"410":{"description":"Recommendation expired","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Parameters failed validation; the recommendation was dismissed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["campaigns:apply"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"header","x-atribu-scope-grain":"profile"}},"/api/v1/recommendations/{id}/dismiss":{"post":{"summary":"Dismiss a recommendation","description":"Marks the recommendation `dismissed` so it stops surfacing. Idempotent — dismissing an already-dismissed recommendation returns `200` with `already_dismissed: true`.\n\n`reason` is recorded on the recommendation (`rationale_jsonb.dismiss_reason`). Until #482 this endpoint accepted it and threw it away while the dashboard stored it; both now take the same write.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","description":"Free-form note, stored on the recommendation."}}}}}},"responses":{"200":{"description":"Dismissed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"recommendation_id":{"type":"string"},"status":{"type":"string","enum":["dismissed"]},"already_dismissed":{"type":"boolean"}},"required":["recommendation_id","status","already_dismissed"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Bad id or malformed body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Recommendation not found for this key's profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Only an `open` recommendation can be dismissed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["campaigns:apply"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/recommendations/{id}/diagnose":{"get":{"summary":"Why did this recommendation do what it did","description":"The full post-mortem for one recommendation: the recommendation itself with its lifecycle timestamps, its `meta_action_log` trail (one row per apply leg, with `pre_change_state`, `result`, a typed `error_code`, and rollback rows linked via `rollback_of`), and any dry-run audit rows it points at.\n\nRead this when an apply reported `failed` or `partial`, or before applying to see what earlier attempts did. `campaigns:read` — it writes nothing, and gating a post-mortem behind the apply scope would make operators hold write access just to find out why a write failed.\n\nThe same three blocks the MCP tool `diagnose_recommendation` returns.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Diagnosis","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"recommendation":{"allOf":[{"$ref":"#/components/schemas/Recommendation"},{"type":"object","properties":{"lifecycle":{"type":"object","properties":{"applied_at":{"type":["string","null"]},"applied_audit_id":{"type":["string","null"]},"dismissed_at":{"type":["string","null"]},"superseded_by_id":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["applied_at","applied_audit_id","dismissed_at","superseded_by_id","created_at"]}},"required":["lifecycle"]}]},"actions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"action_kind":{"type":"string","example":"adset_budget_change"},"tier":{"type":"string","description":"Approval tier the action layer required."},"status":{"type":"string","example":"applied"},"source":{"type":"string","description":"`ui` | `rest` | `mcp` | `worker` | `setup_link`."},"actor_user_id":{"type":["string","null"]},"idempotency_key":{"type":["string","null"]},"summary":{"type":"string"},"target":{"type":"object","additionalProperties":{}},"request_payload":{"type":"object","additionalProperties":{}},"pre_change_state":{"type":["object","null"],"additionalProperties":{}},"result":{"type":["object","null"],"additionalProperties":{}},"rollback_of":{"type":["string","null"]},"created_at":{"type":"string"},"applied_at":{"type":["string","null"]},"rolled_back_at":{"type":["string","null"]},"error":{"type":["object","null"],"properties":{"code":{"type":"string"},"message":{"type":["string","null"]}},"required":["code","message"]}},"required":["id","action_kind","tier","status","source","actor_user_id","idempotency_key","summary","target","request_payload","pre_change_state","result","rollback_of","created_at","applied_at","rolled_back_at","error"]}},"audit_chain":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"created_at":{"type":"string"},"tool_name":{"type":"string"},"mode":{"type":"string","example":"dry_run"},"result":{"type":"string"},"event_count":{"type":"number"},"payload_hash":{"type":"string"},"error":{"type":["object","null"],"properties":{"code":{"type":"string"},"message":{"type":["string","null"]}},"required":["code","message"]}},"required":["id","created_at","tool_name","mode","result","event_count","payload_hash","error"]}}},"required":["recommendation","actions","audit_chain"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Bad id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Recommendation not found for this key's profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/partner/wa-joins":{"post":{"summary":"Join an inbound WhatsApp message to the web session that produced it","description":"Runs Atribu's join tiers in descending determinism — `text_token` (opt-in per profile) → `text_correlation` (the default, invisible tier) → `phone_identity_submitted` → `phone_identity_early` — and, when the join lands, records a `whatsapp_conversation_started` outcome event so the conversation flows through the normal attribution chain.\n\nSupplying `ctwa_clid` (Meta's click-to-WhatsApp click id, resolved on the caller's side) makes that the reported `join_method` while `session_join_method` still names whichever Atribu tier produced the linkage — both facts stay measurable.\n\nA deliberate non-join is a success, not an error: an ambiguous match (two different visitors sending the same prefilled text inside the window) returns `200` with `ambiguous: true` and `conversation_started: false`, because writing an unplaceable conversation would inflate the profile's pipeline count.\n\nIDEMPOTENT — retry it. The recorded event's `external_id` hashes facts about the MESSAGE (merchant phone, sender phone, `occurred_at`, normalised body), all of which a retry resends verbatim, so redelivery converges on one row.\n\nScope: `events:write` OR `whatsapp`.","tags":["WhatsApp Health"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WaJoinRequest"}}}},"responses":{"200":{"description":"Join result — a non-join is still a 200","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WaJoinResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body or an unusable `merchant_phone`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["events:write","whatsapp"],"x-atribu-auth":["api_key"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/openapi.json":{"get":{"summary":"Fetch this OpenAPI document","description":"The live spec, served with permissive CORS and a 1-hour cache. Generated from the same registry that this document is emitted from, so it can never describe a different API than the one you are calling.","tags":["Analytics"],"security":[],"responses":{"200":{"description":"An OpenAPI 3.1 document","content":{"application/json":{"schema":{"type":"object","additionalProperties":{}}}}}},"x-atribu-scopes":[],"x-atribu-auth":["public"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"none"}},"/api/v1/drill":{"get":{"summary":"Drill into a dashboard KPI — the rows behind the number","description":"The row-level detail behind a KPI tile, in seven kinds — the seven `p_kind` values of the canonical conversion-detail read. Dashboard tiles map onto them many-to-one: MER and NMER are ratios over `attributed_revenue`, so both drill to the same rows.\n\n| `kind` | grain | backs |\n|---|---|---|\n| `first_payment` | conversion | new-customer / first-payment counts |\n| `attributed_revenue` | conversion | Revenue, ROAS, MER, NMER |\n| `recurring_customer` | customer | MRR / recurring revenue |\n| `refunded` | conversion | refund rate |\n| `excluded_first` | conversion | the payments the first-payment count left out, and why |\n| `cac_by_channel` | channel | CAC |\n| `spend_by_campaign` | campaign | Spend |\n\n**Dashboard tile aliases.** The console's KPI tiles have their own names, and this route accepts them as `kind` aliases so a caller can send what the tile is called. The response's `kind` is always the CANONICAL one — `mer` and `nmer` both come back as `attributed_revenue`, because the row shape is what `kind` discriminates and there is one shape behind all three. Aliases are not members of the `kind` enum: they are a courtesy on the way in, never part of a response.\n\n| tile alias | canonical `kind` |\n|---|---|\n| `first` | `first_payment` |\n| `revenue` | `attributed_revenue` |\n| `mer` | `attributed_revenue` |\n| `nmer` | `attributed_revenue` |\n| `mrr` | `recurring_customer` |\n| `cac` | `cac_by_channel` |\n| `spend` | `spend_by_campaign` |\n| `refundRate` | `refunded` |\n\n**`include=excluded`.** On `kind=first_payment` only, adds an `excluded` array to the same body: the payments the first-payment count deliberately left out, and why — the same rows `kind=excluded_first` returns on its own. One request instead of two, so a first-payment view cannot render a count from one window beside exclusions from another. Sending it on any other kind is 400 `invalid_parameter` rather than silently ignored, and it is refused with `format=csv` because a CSV body is a single table with a published column order — request `kind=excluded_first&format=csv` for those rows. When it is not sent the key is ABSENT, not empty: `[]` would claim there are none.\n\n**Scopes.** `analytics:read` for every kind, plus `customers:read` for the five that name a person (`first_payment`, `attributed_revenue`, `recurring_customer`, `refunded`, `excluded_first`). A key with `analytics:read` alone reads `cac_by_channel` and `spend_by_campaign` and gets 403 `insufficient_scope` on the rest.\n\n**Every kind is model-aware.** Filters and acquisition touches resolve against the conversion's attributed touch under `model`, so a filtered drill reconciles with the filtered KPI rather than contradicting it.\n\n**Filters** are `filter[<dimension>]=<operator>:<value>`, e.g. `filter[channel]=is:Paid Social`. Only the dimensions the engine can scope **spend** by are accepted (`channel`, `referrer`, `campaign`, `entry_page`, `goal`); anything else is 400 `invalid_parameter`. That is deliberate — a dimension spend cannot be scoped by makes every ratio built on spend silently wrong, so it is refused rather than answered.\n\n**CSV.** Add `format=csv` for a downloadable `text/csv` body — the same rows as the JSON form, `Content-Disposition: attachment` with a filename derived from the kind and the date range. Content negotiation via `Accept` is deliberately NOT used: the header is unreachable from the `<a download>` links that consume this, and a hidden switch between two body types is worse than a visible one. Column order per kind (stable — treat it as contract):\n\n`first_payment`: conversion_id, customer_profile_id, customer_name, customer_email, amount, currency, paid_at, channel, source, campaign_name, ad_name, ad_set_name, provider, first_touch_at, first_touch_source, last_touch_at, last_touch_source, session_count, journey_days, attributed_credit, confidence, payment_kind, possible_duplicate\n\n`attributed_revenue`: conversion_id, customer_profile_id, customer_name, customer_email, amount, currency, paid_at, channel, source, campaign_name, ad_name, ad_set_name, provider, first_touch_at, first_touch_source, last_touch_at, last_touch_source, session_count, journey_days, attributed_credit, confidence, payment_kind, possible_duplicate\n\n`recurring_customer`: customer_profile_id, customer_name, customer_email, recurring_revenue, recurring_payments, lifetime_payments, lifetime_value, first_payment_at, last_payment_at, avg_cadence_days, acquisition_channel, acquisition_source, acquisition_campaign\n\n`refunded`: conversion_id, customer_profile_id, customer_name, customer_email, amount, currency, refunded_at, external_source, reason\n\n`excluded_first`: conversion_id, customer_profile_id, customer_name, customer_email, amount, currency, paid_at, exclude_kind, reason, detail, prior_payment_at, prior_payment_amount\n\n`cac_by_channel`: channel, spend, first_customers, cac\n\n`spend_by_campaign`: campaign_id, platform_id, campaign_name, provider, spend, impressions, clicks, cpm, cpc, ctr, revenue, outcome_count, roas, prev_spend, spend_change_pct","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["first_payment","attributed_revenue","recurring_customer","refunded","excluded_first","cac_by_channel","spend_by_campaign"],"description":"One of the seven canonical kinds. The eight dashboard tile aliases (`first`, `revenue`, `mer`, `nmer`, `mrr`, `cac`, `spend`, `refundRate`) are also accepted — see the tile-alias table in the description — but are not part of this enum and never appear in a response."},"required":true,"description":"One of the seven canonical kinds. The eight dashboard tile aliases (`first`, `revenue`, `mer`, `nmer`, `mrr`, `cac`, `spend`, `refundRate`) are also accepted — see the tile-alias table in the description — but are not part of this enum and never appear in a response.","name":"kind","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string","enum":["excluded"],"description":"`kind=first_payment` only, and JSON only: also return the excluded first payments in the same body. 400 on any other kind or with `format=csv`."},"required":false,"description":"`kind=first_payment` only, and JSON only: also return the excluded first payments in the same body. 400 on any other kind or with `format=csv`.","name":"include","in":"query"},{"schema":{"type":"string"},"required":false,"name":"model","in":"query"},{"schema":{"type":"string","description":"Max rows (default 200, max 500). Ignored for `cac_by_channel`, whose grain is bounded by the channel list."},"required":false,"description":"Max rows (default 200, max 500). Ignored for `cac_by_channel`, whose grain is bounded by the channel list.","name":"limit","in":"query"},{"schema":{"type":"string","enum":["json","csv"],"description":"Response body format. Default `json`."},"required":false,"description":"Response body format. Default `json`.","name":"format","in":"query"},{"schema":{"type":"string","description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only `channel`, `referrer`, `campaign`, `entry_page`, `goal` are accepted — the dimensions the engine can scope AD SPEND by. Anything else, or a malformed entry, is a 400 `invalid_parameter` naming the dimension — never silently dropped (#625).","example":"is:Paid Social"},"required":false,"description":"Filter by channel: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only `channel`, `referrer`, `campaign`, `entry_page`, `goal` are accepted — the dimensions the engine can scope AD SPEND by. Anything else, or a malformed entry, is a 400 `invalid_parameter` naming the dimension — never silently dropped (#625).","name":"filter[channel]","in":"query"},{"schema":{"type":"string","description":"Filter by referrer: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only `channel`, `referrer`, `campaign`, `entry_page`, `goal` are accepted — the dimensions the engine can scope AD SPEND by. Anything else, or a malformed entry, is a 400 `invalid_parameter` naming the dimension — never silently dropped (#625)."},"required":false,"description":"Filter by referrer: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only `channel`, `referrer`, `campaign`, `entry_page`, `goal` are accepted — the dimensions the engine can scope AD SPEND by. Anything else, or a malformed entry, is a 400 `invalid_parameter` naming the dimension — never silently dropped (#625).","name":"filter[referrer]","in":"query"},{"schema":{"type":"string","description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only `channel`, `referrer`, `campaign`, `entry_page`, `goal` are accepted — the dimensions the engine can scope AD SPEND by. Anything else, or a malformed entry, is a 400 `invalid_parameter` naming the dimension — never silently dropped (#625)."},"required":false,"description":"Filter by campaign: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only `channel`, `referrer`, `campaign`, `entry_page`, `goal` are accepted — the dimensions the engine can scope AD SPEND by. Anything else, or a malformed entry, is a 400 `invalid_parameter` naming the dimension — never silently dropped (#625).","name":"filter[campaign]","in":"query"},{"schema":{"type":"string","description":"Filter by entry_page: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only `channel`, `referrer`, `campaign`, `entry_page`, `goal` are accepted — the dimensions the engine can scope AD SPEND by. Anything else, or a malformed entry, is a 400 `invalid_parameter` naming the dimension — never silently dropped (#625)."},"required":false,"description":"Filter by entry_page: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only `channel`, `referrer`, `campaign`, `entry_page`, `goal` are accepted — the dimensions the engine can scope AD SPEND by. Anything else, or a malformed entry, is a 400 `invalid_parameter` naming the dimension — never silently dropped (#625).","name":"filter[entry_page]","in":"query"},{"schema":{"type":"string","description":"Filter by goal: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only `channel`, `referrer`, `campaign`, `entry_page`, `goal` are accepted — the dimensions the engine can scope AD SPEND by. Anything else, or a malformed entry, is a 400 `invalid_parameter` naming the dimension — never silently dropped (#625)."},"required":false,"description":"Filter by goal: `<operator>:<value>`, where operator is `is` | `is_not` | `contains` | `not_contains`. Only `channel`, `referrer`, `campaign`, `entry_page`, `goal` are accepted — the dimensions the engine can scope AD SPEND by. Anything else, or a malformed entry, is a 400 `invalid_parameter` naming the dimension — never silently dropped (#625).","name":"filter[goal]","in":"query"}],"responses":{"200":{"description":"Drill rows","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DrillResult"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}},"text/csv":{"schema":{"type":"string","description":"RFC 4180 CSV, UTF-8 with no byte-order mark, CRLF line endings, one header row. Returned when `format=csv`."}}}},"400":{"description":"Unknown kind, unscopable filter, or a bad date range","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The key lacks `customers:read` for a kind that returns customer identity","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/attribution/models":{"get":{"summary":"Compare attribution models side by side","description":"Per-campaign credit distribution under each attribution model for one date range — the dataset Atribu's own Attribution view renders when you set a model and a comparison model.\n\nTotal credited value is the SAME under every model: a conversion's credit weights sum to 1 across its whole path regardless of model. What differs is which campaigns receive that credit, which is the question this endpoint answers. Comparing `totals` between models is therefore not the signal — comparing a campaign's `credited_conversions` or `direct_cash_value` between models is.\n\nRequires `analytics:read`. Heavy: one credit rollup + one spend read per model.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"2026-08-01","description":"Start date (YYYY-MM-DD, inclusive)"},"required":true,"description":"Start date (YYYY-MM-DD, inclusive)","name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-08-20","description":"End date (YYYY-MM-DD, inclusive). Max range: 366 days."},"required":true,"description":"End date (YYYY-MM-DD, inclusive). Max range: 366 days.","name":"date_to","in":"query"},{"schema":{"type":"string","example":"last_touch,linear","description":"Comma-separated subset of attribution models to compare. Defaults to all of them: last_touch, first_touch, linear, time_decay, last_non_direct."},"required":false,"description":"Comma-separated subset of attribution models to compare. Defaults to all of them: last_touch, first_touch, linear, time_decay, last_non_direct.","name":"models","in":"query"}],"responses":{"200":{"description":"Per-model campaign credit distribution","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"models":{"type":"array","items":{"$ref":"#/components/schemas/AttributionModelSlice"}}},"required":["models"]},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/attribution/explore":{"get":{"summary":"Explain one conversion's attribution credit","description":"Why a single conversion was credited the way it was: the per-touchpoint credit under the requested model, the resolution strategy that found the ad entity, and the customer's earlier outcome events (oldest first, capped at 100).\n\nPass `compare=true` to add a `models` array carrying the same conversion's credit under every attribution model — the per-conversion view of the side-by-side that `/api/v1/attribution/models` gives for a whole date range.\n\nRequires `customers:read`: the response names the customer profile and the customer key, and the timeline is that customer's history.","tags":["Customers"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The outcome event to explain.","example":"8c4f1b2a-0000-4000-8000-000000000000"},"required":true,"description":"The outcome event to explain.","name":"outcome_event_id","in":"query"},{"schema":{"type":"string","example":"last_touch","description":"Attribution model. One of: last_touch, first_touch, linear, time_decay, last_non_direct. Defaults to last_touch."},"required":false,"description":"Attribution model. One of: last_touch, first_touch, linear, time_decay, last_non_direct. Defaults to last_touch.","name":"model","in":"query"},{"schema":{"type":"string","example":"true","description":"Set to `true` to include the per-model credit comparison."},"required":false,"description":"Set to `true` to include the per-model credit comparison.","name":"compare","in":"query"}],"responses":{"200":{"description":"Credit explanation for one conversion","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AttributionExplorer"},"meta":{"$ref":"#/components/schemas/Meta"}},"required":["data","meta"]}}}}},"x-atribu-scopes":["customers:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/tracking/keys":{"get":{"summary":"List the profile's active tracking keys","description":"The delegated read for a dealer's tracking-key setup. Returns every ACTIVE key on the key's own profile — never another profile's, and never a revoked one, unlike the internal dashboard list which shows full history for key management. `public_key` is included: it is a public identifier that already ships inside the dealer's site (see `TrackingKey`'s field description), so there is nothing to redact.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The profile's active tracking keys","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TrackingKey"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"example":{"data":[{"id":"c9d0e1f2-a3b4-4c5d-6e7f-8a9b0c1d2e3f","name":"default","status":"active","public_key":"trk_live_9f1c2b3a4d5e6f708192a3b4c5d6e7f8","last_seen_at":"2026-08-28T02:00:00.000Z","created_at":"2026-06-01T10:00:00.000Z"}],"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Issue a tracking key (idempotent)","description":"Issues a tracking key for the profile IF NONE is currently active. Call it as often as you like during onboarding — a second call with no active key having been revoked in between returns the SAME key, unchanged, with HTTP 200 and `created: false`; only the call that actually mints a key returns HTTP 201 with `created: true`. `name` is only used the first time.\n\nSend `force_new: true` to mint a key even when one is already active (always 201). That is the rotation path: deploy the new key, then revoke the old one with `DELETE /api/v1/tracking/keys/{id}` — which refuses to revoke the last active key, so the order matters.\n\nRequires the `tracking:write` scope — a separate grant from the read routes on this surface, because minting a key is a write, even though the key material itself is a public identifier (see `TrackingKey`'s `public_key` description).","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssueTrackingKeyRequest"}}}},"responses":{"200":{"description":"An active key already existed — returned unchanged","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IssuedTrackingKey"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"201":{"description":"A new key was minted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IssuedTrackingKey"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the tracking:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["tracking:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"natural_key","x-atribu-scope-grain":"profile"}},"/api/v1/tracking/installers/gtm":{"get":{"summary":"Get the Google Tag Manager installer payload","description":"The same payload the internal Atribu dashboard's GTM installer returns — byte-identical field names and snippet content for the same key/origin, because both surfaces call the same builder (`@atribu/analytics-enrichment/tracking/installers`). Requires an active tracking key on the profile; issue one first with `POST /api/v1/tracking/keys` if none exists.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Use this specific active key instead of the most recently created one."},"required":false,"description":"Use this specific active key instead of the most recently created one.","name":"key_id","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resolve the collect endpoint against this verified custom tracking domain instead of the most recently verified one. Must belong to the key's own profile."},"required":false,"description":"Resolve the collect endpoint against this verified custom tracking domain instead of the most recently verified one. Must belong to the key's own profile.","name":"domain_id","in":"query"}],"responses":{"200":{"description":"The GTM tag installer payload","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/GtmInstaller"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No active tracking key found for this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/tracking/snippet":{"get":{"summary":"Get the raw tracker snippet (with optional Meta Pixel bundle)","description":"The same payload the internal Atribu dashboard's snippet endpoint returns — byte-identical field names and snippet content for the same key/origin/settings, because both surfaces call the same builder. Requires an active tracking key on the profile.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Use this specific active key instead of the most recently created one."},"required":false,"description":"Use this specific active key instead of the most recently created one.","name":"key_id","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resolve the collect endpoint against this verified custom tracking domain instead of the most recently verified one. Must belong to the key's own profile."},"required":false,"description":"Resolve the collect endpoint against this verified custom tracking domain instead of the most recently verified one. Must belong to the key's own profile.","name":"domain_id","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"When true and the profile has a Meta CAPI pixel configured, also returns `combinedSnippet` (Meta Pixel + Atribu tracker, in load order) and `metaPixelId`."},"required":false,"description":"When true and the profile has a Meta CAPI pixel configured, also returns `combinedSnippet` (Meta Pixel + Atribu tracker, in load order) and `metaPixelId`.","name":"include_meta_pixel","in":"query"}],"responses":{"200":{"description":"The tracker snippet","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TrackingSnippet"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No active tracking key found for this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/tracking/installers/shopify-pixel":{"get":{"summary":"Get the Shopify Web Pixel installer payload","description":"The same payload the internal Atribu dashboard's Shopify pixel installer returns — byte-identical field names and extension code for the same key/origin/settings. Requires an active tracking key on the profile.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Use this specific active key instead of the most recently created one."},"required":false,"description":"Use this specific active key instead of the most recently created one.","name":"key_id","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resolve the collect endpoint against this verified custom tracking domain instead of the most recently verified one. Must belong to the key's own profile."},"required":false,"description":"Resolve the collect endpoint against this verified custom tracking domain instead of the most recently verified one. Must belong to the key's own profile.","name":"domain_id","in":"query"}],"responses":{"200":{"description":"The Shopify Web Pixel installer payload","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ShopifyPixelInstaller"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No active tracking key found for this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profiles":{"post":{"summary":"Provision a dealer (app-authenticated)","description":"Creates an Atribu tenant for one of your dealers and grants your app access to it, in a single call. No end-user browser bounce is involved.\n\n**Authentication is different from every other endpoint.** Send your app's `client_id` and `client_secret` as HTTP Basic auth — *not* an `atb_live_` key. There is no key to send: this is the call that creates the profile a key would be scoped to.\n\n**What it creates.** A passwordless owner user from `email`, a workspace of its own for this dealer (one workspace per dealer, so workspace-level views can never mix two of them), a profile inside it, and the authorization grant carrying `scopes`.\n\n**Getting a key — the sequence.** This endpoint returns NO key material, on purpose. Once you hold `profile_id`, mint keys yourself:\n\n```\nPOST /oauth/token\nAuthorization: Basic base64(client_id:client_secret)\nContent-Type: application/x-www-form-urlencoded\n\ngrant_type=client_credentials&profile_id=<profile_id>\n```\n\nThat returns an `atb_live_` access token scoped to the dealer, which every other endpoint accepts as `Authorization: Bearer`. It works because the grant this call wrote already exists — `client_credentials` mints tokens for access you hold, it can never create access. Mint on your own schedule; keys stay valid until revoked, up to 5 live per dealer.\n\n**Idempotency.** Keyed on (your app, `external_ref`). A repeat call returns the same `profile_id` / `workspace_id` / `grant_id` with HTTP 200 and `created: false`; only the first returns 201. A repeat with different `scopes` updates the grant in place, which is how you widen or narrow a dealer's delegation.\n\n**#488 (PR-2) — a SECOND, unrelated way to call this endpoint.** Send a Supabase session bearer (`Authorization: Bearer`, the credential Atribu's own console uses) instead of Basic auth, and the body shape changes to `SessionProvisionProfileRequest`: an owner/admin adds a profile to a workspace they already run. No workspace, shadow user or grant is created — see that schema's own description. The two branches share a URL and nothing else; a consumer app's integration is entirely unaffected by this branch existing.\n\n**#1067 — the session branch enforces the workspace's `active_profiles` plan limit.** A workspace already at its cap answers `403 profile_limit_reached` with `upgrade_required: true` and `upgrade: { plan }`; check `GET /api/v1/workspaces/{workspaceId}/subscription`'s `limits.active_profiles` before calling if you want to avoid it. The Basic-auth branch is NOT subject to this limit — each call provisions a brand-new workspace holding exactly one profile, and a consumer app's dealer count is governed by its own commercial agreement, not `PLANS.starter.limits`.","tags":["Provisioning"],"security":[{"clientSecretBasic":[]},{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProvisionProfileRequest"},{"$ref":"#/components/schemas/SessionProvisionProfileRequest"}]}}}},"responses":{"200":{"description":"This dealer was already provisioned — same ids returned, nothing created (Basic-auth branch only; the session branch never returns 200)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProvisionedProfile"}},"required":["data"]},"example":{"data":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb","workspace_id":"3c9b1e2a-6f4d-4a8b-9c1e-2a6f4d4a8b9c","grant_id":"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d","created":true,"scopes":["analytics"]}}}}},"201":{"description":"Dealer provisioned, or (session branch) the new profile created","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProvisionedProfile"}},"required":["data"]},{"type":"object","properties":{"data":{"$ref":"#/components/schemas/SessionProvisionedProfile"}},"required":["data"]}]}}}},"400":{"description":"Malformed body, unknown field, or a scope outside the app's allowed_scopes / outside the profile-level set","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"},"upgrade_required":{"type":"boolean"},"upgrade":{"type":"object","properties":{"plan":{"type":"string"}},"required":["plan"]},"docs_url":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing, malformed, unknown or suspended client credentials, or an invalid/expired session","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"},"upgrade_required":{"type":"boolean"},"upgrade":{"type":"object","properties":{"plan":{"type":"string"}},"required":["plan"]},"docs_url":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Session branch only (#1067): the workspace has reached its plan's active_profiles limit. Carries upgrade_required: true and upgrade: { plan } on the error envelope.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"},"upgrade_required":{"type":"boolean"},"upgrade":{"type":"object","properties":{"plan":{"type":"string"}},"required":["plan"]},"docs_url":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Session branch only: the workspace does not exist, or you are not an active owner/admin member","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"},"upgrade_required":{"type":"boolean"},"upgrade":{"type":"object","properties":{"plan":{"type":"string"}},"required":["plan"]},"docs_url":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"},"upgrade_required":{"type":"boolean"},"upgrade":{"type":"object","properties":{"plan":{"type":"string"}},"required":["plan"]},"docs_url":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"500":{"description":"Provisioning succeeded but the grant could not be recorded — retry","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"},"upgrade_required":{"type":"boolean"},"upgrade":{"type":"object","properties":{"plan":{"type":"string"}},"required":["plan"]},"docs_url":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":[],"x-atribu-auth":["app_credential","session"],"x-atribu-idempotency":"natural_key","x-atribu-scope-grain":"profile"},"get":{"summary":"List the profiles the signed-in user can reach in this workspace","description":"The profile switcher's list: every profile in the authenticated profile's workspace that the SIGNED-IN USER may open, ordered by name.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so there is no set of profiles for a key to enumerate and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses) to call it.\n\nA workspace owner, admin or analyst sees every unarchived profile in the workspace; a guest sees only the profiles they hold a membership on.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Optional assertion that the profile you named lives in the workspace you think it does. When present and it does not, the answer is 404 — the same answer a profile you cannot reach gets, so the status code never becomes a way to enumerate workspaces. When absent, the workspace is taken from the authenticated profile."},"required":false,"description":"Optional assertion that the profile you named lives in the workspace you think it does. When present and it does not, the answer is 404 — the same answer a profile you cannot reach gets, so the status code never becomes a way to enumerate workspaces. When absent, the workspace is taken from the authenticated profile.","name":"workspace_id","in":"query"}],"responses":{"200":{"description":"The profiles this user can reach","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceProfile"}},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The named workspace is not the authenticated profile's","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/reports":{"get":{"summary":"List generated reports","description":"Every report generated for this profile, newest first — whoever generated it: a person in Atribu, the recurring schedule, or this API.\n\n**Summaries, not documents.** Each row carries the identity, the window and four headline numbers. Fetch `GET /api/v1/reports/{id}` for the full payload; a collection that inlined twenty of those would ship megabytes to answer which reports exist.\n\nPage with `created_before` (the `created_at` of the last row you saw).\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":50,"description":"Page size, capped at 50. Defaults to 25."},"required":false,"description":"Page size, capped at 50. Defaults to 25.","name":"limit","in":"query"},{"schema":{"type":"string","description":"ISO timestamp — return reports strictly older than this."},"required":false,"description":"ISO timestamp — return reports strictly older than this.","name":"created_before","in":"query"}],"responses":{"200":{"description":"The page of reports","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ReportSummary"}},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]},"example":{"data":[{"id":"b8c9d0e1-f2a3-4b4c-5d6e-7f8a9b0c1d2e","type":"custom","date_from":"2026-08-01","date_to":"2026-08-31","model":"last_touch","created_at":"2026-09-01T08:00:00.000Z","share_slug":"rpt_9f1c2b3a4d5e6f70","currency":"CLP","summary":{"spend":"845200.00"}}],"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/reports/schedules/changes":{"get":{"summary":"List report schedule changes","description":"The audit tail of this profile's report schedule, newest first — what the cadence and the recipient count were left at by each change, and when.\n\n**No actor and no addresses.** Who made the change is an Atribu identity, not yours, and the recipient list is configuration that rides `reports:write` — so this read publishes neither; `recipient_count` is what it gives you.\n\nRows outlive the schedule they describe, so this can return history for a profile whose schedule has since been deleted.\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":50,"description":"Page size, capped at 50. Defaults to 10."},"required":false,"description":"Page size, capped at 50. Defaults to 10.","name":"limit","in":"query"}],"responses":{"200":{"description":"The change log page","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ReportScheduleChange"}},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/reports/templates":{"get":{"summary":"List report templates","description":"Every report template in your profile's workspace, default first. A template is what a schedule points at — pin one with `template_id` on `POST /api/v1/reports/schedules`.\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The workspace's templates","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ReportTemplate"}},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Create a report template","description":"Creates a report template in your profile's workspace. `schema` is the console's template document — an ordered list of sections, each with its own per-type config — validated server-side and its commentary HTML sanitized before storage, so a caller that bypasses the console's editor cannot store anything unsafe to render.\n\n**`is_default: true` clears the workspace's previous default atomically** — one round trip, not two separate calls a concurrent request could interleave.\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportTemplateWrite"}}}},"responses":{"201":{"description":"The created template","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ReportTemplate"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["reports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/reports/templates/{id}":{"patch":{"summary":"Update a report template","description":"Partial update. Fields you omit are left alone. `is_default: true` clears the workspace's previous default atomically, the same as on create.\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The template id."},"required":true,"description":"The template id.","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportTemplateUpdate"}}}},"responses":{"200":{"description":"The updated template","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ReportTemplate"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such template in this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["reports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"delete":{"summary":"Delete a report template","description":"Deletes a report template. Any schedule pinned to it (`template_id`) has that column set to `null` by the database, not cleared by this call — the next report that schedule generates falls through to the workspace default, then the system default. Reports already generated under this template are unaffected: `Report.template_snapshot` is a frozen copy with no reference back to this row.\n\nA delete that matches nothing answers 404, never 204.\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The template id."},"required":true,"description":"The template id.","name":"id","in":"path"}],"responses":{"204":{"description":"Deleted"},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such template in this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["reports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/reports/preview":{"post":{"summary":"Preview report data without storing it","description":"Builds report data the SAME way `POST /api/v1/reports/generate` does, under either an existing template (`template_id`) or an unsaved draft (`template_schema`) — exactly one is required — but never inserts a `reports` row. Nothing here shows up in `GET /api/v1/reports` and its `id` resolves against no other route: it exists only in this response.\n\nRides `analytics:read`, not `reports:write` — nothing is persisted, so this is a read, the same floor `GET /api/v1/reports/{id}` uses.\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportPreviewRequest"}}}},"responses":{"200":{"description":"The preview","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Report"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/reports/send-test":{"post":{"summary":"Send a test report email","description":"Builds a report over a trailing window (`window_days`, default 7) under the profile's own report-schedule model and mails it — but NEVER stores it: no `reports` row, and the send-log row's `report_id` is `null` because there is no stored report to link it to.\n\n`recipients` defaults to the profile's own schedule recipients; pass it explicitly to test against a different list. Either way, at least one recipient is required — a profile with no schedule and no explicit `recipients` gets a 400, not a silent no-op.\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportSendTestRequest"}}}},"responses":{"200":{"description":"Sent","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ReportSendTestResult"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"503":{"description":"This deployment has no email provider configured","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["reports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/reports/schedules":{"get":{"summary":"Get the report schedule","description":"A profile has AT MOST ONE report schedule, so this returns a list of length 0 or 1.\n\n`recipient_emails` is returned only to a key holding `reports:write`; otherwise it is `null` and `recipient_count` tells you whether there are any.\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The schedule, or an empty list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ReportSchedule"}},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Create or update the report schedule","description":"Configures the recurring report for this profile: cadence, hour, attribution model, language, template and recipients.\n\n**Idempotent, with no `Idempotency-Key`.** A profile can have only one schedule (`report_schedules` is unique on the profile), so the tenancy IS the key: the first call returns 201, every later call updates the same row and returns 200. Fields you omit are left exactly as they were — a retry after a partial failure cannot blank what an earlier call set.\n\n**Enabling a schedule with no recipients is rejected**, because it would mail nobody on every tick for as long as it stayed on.\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportScheduleWrite"}}}},"responses":{"200":{"description":"The schedule already existed and was updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ReportSchedule"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"201":{"description":"The schedule was created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ReportSchedule"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["reports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/reports/schedules/{id}":{"patch":{"summary":"Update the report schedule","description":"Partial update. Fields you omit are left alone; `next_scheduled_at` moves only when the cadence actually moved, so changing the template does not silently reschedule the next send.\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The schedule id."},"required":true,"description":"The schedule id.","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportScheduleWrite"}}}},"responses":{"200":{"description":"The updated schedule","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ReportSchedule"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such schedule on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["reports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"delete":{"summary":"Delete the report schedule","description":"Removes the schedule entirely; no further reports are sent. To pause instead, `PATCH` it with `enabled: false` — that keeps the recipients and cadence.\n\nA delete that matches nothing answers 404, never 200: being told a schedule is gone when it is still firing is the one wrong answer here.\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The schedule id."},"required":true,"description":"The schedule id.","name":"id","in":"path"}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}},"required":["id","deleted"]},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such schedule on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["reports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/reports/deliveries":{"get":{"summary":"List report deliveries","description":"The send ledger for this profile, newest first — every report that went out, from any path (a person clicking send, the recurring schedule, or this API).\n\n**No addresses and no message bodies.** Atribu does not store either on a send-log row, so neither can appear here. What you get is whether it went out, when, to how many, and what the provider said afterwards.\n\nPage with `sent_before` (the `sent_at` of the last row you saw).\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"description":"Page size, capped at 100. Defaults to 25."},"required":false,"description":"Page size, capped at 100. Defaults to 25.","name":"limit","in":"query"},{"schema":{"type":"string","description":"ISO timestamp — return sends strictly older than this."},"required":false,"description":"ISO timestamp — return sends strictly older than this.","name":"sent_before","in":"query"},{"schema":{"type":"string","description":"Restrict to one report's sends."},"required":false,"description":"Restrict to one report's sends.","name":"report_id","in":"query"}],"responses":{"200":{"description":"The ledger page","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ReportDelivery"}},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/reports/generate":{"post":{"summary":"Generate a report","description":"Builds a report over a date range and stores it. Returns the report's id and its full data payload — **not** HTML and not a PDF. Rendering is yours to do; the hosted view is linked by `share_slug`.\n\n**Idempotency.** Send an `Idempotency-Key` header and a repeat call returns the SAME report with HTTP 200 instead of building a second one. Derive the key from what makes this generation unique to you — profile, template and range. This matters more than usual here: attribution is recomputed continuously, so re-running the same window on a later day legitimately produces different numbers, and a retried request must not hand you a second, disagreeing report. Omit the header to always generate a new one.\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"2026-08-01"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-08-31"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string","example":"last_touch","description":"Attribution model. Defaults to `last_touch`."},"required":false,"description":"Attribution model. Defaults to `last_touch`.","name":"model","in":"query"},{"schema":{"type":"string","maxLength":255,"description":"Optional. Scoped to your profile; a repeat returns the first report with 200."},"required":false,"description":"Optional. Scoped to your profile; a repeat returns the first report with 200.","name":"Idempotency-Key","in":"header"}],"responses":{"200":{"description":"An `Idempotency-Key` replay — the existing report, unchanged","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Report"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"201":{"description":"The report was generated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Report"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["reports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/reports/{id}":{"get":{"summary":"Fetch a generated report","description":"The stored report, exactly as it was frozen at generation time — including its data payload. Numbers do not drift underneath a report once generated, which is the point of storing it.\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The report id."},"required":true,"description":"The report id.","name":"id","in":"path"}],"responses":{"200":{"description":"The report","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Report"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such report on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"delete":{"summary":"Delete a generated report","description":"Deletes a stored report and its record in `GET /api/v1/reports`. Best-effort removes the rendered PDF artifact, if one was ever generated; a storage failure there is logged and never fails this request.\n\nSend history rows in `GET /api/v1/reports/deliveries` that reference this report survive the delete — their `report_id` becomes `null` rather than the row disappearing, so the ledger still shows the send happened.\n\nA delete that matches nothing answers 404, never 204: being told a report is gone when it never existed (or belongs to another tenant) is the one wrong answer here.\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The report id."},"required":true,"description":"The report id.","name":"id","in":"path"}],"responses":{"204":{"description":"Deleted"},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such report on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["reports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/public/reports/{share_slug}":{"get":{"summary":"Fetch a shared report (no credential)","description":"The one endpoint on this API that takes **no credential**. It serves the report behind a share link — the page an agency's client opens from a report email, having no Atribu account of their own.\n\n**The slug is the credential.** `share_slug` is 128 bits of randomness behind a unique constraint; holding one is the whole authorization, exactly as it is for the hosted page at `/reports/{share_slug}`. Treat it like a secret: anyone you send it to can read the report.\n\n**Every slug that does not resolve gets the same 404** — unknown, deleted, malformed, alike — so this route cannot be used to learn which links are real.\n\n**Only the document.** You get the stored report and the branding needed to draw it. You do not get the report's id, its workspace or profile, who generated it, the schedule that produced it, or the addresses it was mailed to. Those live on the authenticated `/api/v1/reports/**` surface, behind `analytics:read`.\n\nResponses are sent `Cache-Control: private, no-store` and are rate limited per report and per IP address.","tags":["Reports"],"security":[],"parameters":[{"schema":{"type":"string","description":"The report's share slug — 32 hex characters.","example":"9f1c2b7d4e6a08f35c21d9be47a03f16"},"required":true,"description":"The report's share slug — 32 hex characters.","name":"share_slug","in":"path"}],"responses":{"200":{"description":"The shared report","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicSharedReport"}},"required":["data"]}}}},"404":{"description":"The slug does not resolve. Identical for every reason it might not, by design.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Too many requests for this report, or from this address","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"500":{"description":"Something went wrong on our side","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":[],"x-atribu-auth":["public"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/reports/{id}/send":{"post":{"summary":"Email a generated report","description":"Mails an already-generated report to the addresses you name, through the same delivery path Atribu's own \"send now\" button uses — same branding, same sender domain, same per-client language, and a row in the ledger `GET /api/v1/reports/deliveries` reads.\n\nGenerate first: this endpoint never builds a report.\n\n**Not idempotent, deliberately.** Calling it twice sends twice, because that is occasionally what you want (a recipient who lost the mail) and a silently-swallowed second send would be impossible to distinguish from a failed first one. Guard it on your side.\n\nReturns 503 when the deployment has no email provider configured — never a 200 for a mail nobody sent.\n\n**Scopes.** Reads require `analytics:read`. Creating, changing or deleting a schedule, generating a report and sending one all require `reports:write`, which is minted by the `attribution_write` delegation.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The report id."},"required":true,"description":"The report id.","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportSendRequest"}}}},"responses":{"200":{"description":"Handed to the email provider","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ReportSendResult"},"meta":{"type":"object","properties":{"date_from":{"type":"string"},"date_to":{"type":"string"},"profile_id":{"type":"string"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such report on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"The report has no stored data and cannot be sent","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"502":{"description":"The email provider rejected the send","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"503":{"description":"Email delivery is not configured on this deployment","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["reports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/workspaces/{workspaceId}/reports/hub":{"get":{"summary":"The workspace Reports hub","description":"One row per client profile you can reach in this workspace: its schedule status, cadence, recipient count, last/next send, and its most recent generated report — plus the workspace's report templates, for a bulk-apply picker. Bundled into one call because the console's hub page needs both on first paint.\n\nA guest is narrowed to the profiles they hold a `profile_memberships` row on; owner/admin/analyst see every profile in the workspace.\n\n**Session bearers only.** An `atb_live_` key is minted for exactly one Profile and has no workspace to read across — `insufficient_scope` (403) naming the profile-scoped alternative. Construct the client with a Supabase session token.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The hub","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ReportsHub"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The key lacks the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/reports/schedules/bulk":{"post":{"summary":"Apply a schedule patch across many profiles","description":"Applies a PARTIAL schedule patch to every profile id in `profile_ids` — cadence, hour, attribution model, language, template. Every field defaults to \"leave unchanged\"; at least one must be set.\n\n**`recipient_emails` cannot be sent here and is rejected outright, not silently ignored** — recipients are per-client and this endpoint must never be mistaken for a way to set them in bulk.\n\n**Per-profile failures are reported in `results`, never swallowed.** A 200 means \"the batch ran\", not \"everything worked\" — there is no transaction, and 39 clients succeeding with 1 failing is a better outcome than 40 rolled back because one had no recipients. Only request-level problems (a profile id outside your reach, a template from another workspace, malformed cadence values) fail the whole request.\n\n**Session bearers only.** An `atb_live_` key is minted for exactly one Profile and has no workspace to read across — `insufficient_scope` (403) naming the profile-scoped alternative. Construct the client with a Supabase session token.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportSchedulesBulkRequest"}}}},"responses":{"200":{"description":"The batch ran — read `results` for the per-profile outcome","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ReportSchedulesBulkResult"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"One or more profile ids are not accessible in this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["reports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/reports/bulk-generate":{"post":{"summary":"Generate (and optionally send) a report across many profiles","description":"Generates one report per profile id in `profile_ids`, over the same `date_from` / `date_to` window, each under **that profile's own** attribution model (from its schedule, or the platform default) — never a model shared across the batch. When `send_email` is true, also mails each generated report to **that profile's own** schedule recipients; a profile with none gets `error: \"no_recipients\"` and its report still exists.\n\n**Per-profile failures are reported in `results`, never swallowed** — same contract as the schedule bulk apply. `generated: true, emailed: false` with an `error` is a normal, common outcome: the report exists and is listed in `GET /api/v1/reports`, only the email had nowhere to go or failed to send.\n\nBounded to 20 profile ids and a 92-day window per request — this fans out into `profile_ids.length` full report builds.\n\n**Session bearers only.** An `atb_live_` key is minted for exactly one Profile and has no workspace to read across — `insufficient_scope` (403) naming the profile-scoped alternative. Construct the client with a Supabase session token.","tags":["Reports"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportBulkGenerateRequest"}}}},"responses":{"200":{"description":"The batch ran — read `results` for the per-profile outcome","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ReportBulkGenerateResponse"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, unknown field, or an invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"One or more profile ids are not accessible in this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"503":{"description":"send_email was true but this deployment has no email provider configured","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["reports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/profile":{"get":{"summary":"Get the authenticated profile's rendering context","description":"Identity and settings for the profile this credential is acting on: its name, currency, timezone, mode, default attribution model and onboarding state, plus the caller's own permissions and the workspace's plan tier.\n\n`onboarding_state` (#1056) is the wizard's own in-progress draft — current step, business type, the draft attribution-model pick, GHL stage-mapping edits and tracking sub-step — null before its first write. `onboarding` is the reduced summary (`{completed, current_step, updated_at}`) that `GET /api/v1/profiles` also carries, so an agent can see how far ANY reachable profile got without reading its full draft.\n\nEverything numeric this API returns is denominated in `currency` and bucketed by day in `timezone`, so a client that renders money or dates should read this first rather than assume USD/UTC.\n\nContains no secrets, no billing amounts and no credit balances.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The profile's rendering context","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProfileContext"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The profile does not exist, or this caller cannot reach it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"patch":{"summary":"Update the authenticated profile's settings","description":"A PARTIAL update over `mode`, `default_attribution_model`, `currency`, `onboarding_completed` and `onboarding_state` — an omitted key is left untouched. Returns the same `ProfileContext` shape `GET /api/v1/profile` does.\n\nSetting `onboarding_completed: true` stamps a `PROFILE_ONBOARDING_COMPLETED` audit row in the same call; `onboarding_skip_reason: \"reviewer_skip\"` records that it was the reviewer-skip path rather than a normal finish. `onboarding_state` (#1056) is a WHOLE-OBJECT replace of the onboarding wizard's in-progress draft, so a human and an agent share one view of how far a profile got. None of these fields trigger an attribution recompute — nothing here redefines which historical rows are attributed to what, unlike a channel-rule or conversion-definition change.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileUpdate"}}}},"responses":{"200":{"description":"The profile's updated rendering context","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProfileContext"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the attribution:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The profile does not exist, or this caller cannot reach it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/workspaces":{"get":{"summary":"List the workspaces the signed-in user belongs to","description":"Every workspace the caller holds an ACTIVE membership in, with their role in each and the workspace's unarchived profile count, ordered by name.\n\nThis is the COLD-BOOT read: it names no workspace and no profile, because it is what a client calls before it has either. Every other route on this surface presupposes an answer to it.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\nA user with no memberships gets an empty list, not a refusal — there is nothing here to be unauthorized for.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The caller's workspaces","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceSummary"}},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"},"post":{"summary":"Create a workspace","description":"Creates a brand-new workspace.\n\n**Two unrelated ways to call this endpoint, sharing one URL** — the same shape `POST /api/v1/profiles` uses, for the same reason.\n\n**Session bearer** (`Authorization: Bearer`, a Supabase session access token, or an MCP user token): you create your OWN new workspace and become its owner. Body: `SessionWorkspaceRequest`. A second workspace for the same caller is allowed — the plan gate is on `active_profiles` per workspace, not on how many workspaces one person owns.\n\n**HTTP Basic** (`client_id:client_secret`, a registered consumer app): you create a bare workspace for one of YOUR OWN users, identified by `email`. Body: `AppWorkspaceRequest`. No profile and no delegation grant are created here — follow with your own `POST /api/v1/profiles` call (Basic auth) if you also need those.\n\n**The session branch is idempotent on `(caller, name)` for a short window** — a retried call with the same `name` returns the SAME workspace with `created: false` and `200`, instead of a duplicate; a call minutes later with the same `name` is a genuinely new workspace. The app-credential branch has no idempotency key at all — every call creates, and `created` is always `true`. A replay never mints a second checkout hand-off either — `checkout` is `null` on the `created: false` response.\n\n**`plan: \"growth\" | \"agency\"` also mints a Stripe Checkout hand-off**, returned as `checkout`: give the human its `url`, poll `GET /api/v1/handoffs/{id}` the way `POST /api/v1/workspaces/{workspaceId}/checkout-session` already works. Minting is never allowed to block workspace creation — a failed mint (unconfigured billing, Stripe refusing the request) still answers 201 with `checkout: null` and a `warnings` entry naming `checkout_unavailable`; retry the mint alone with `POST /api/v1/workspaces/{workspaceId}/checkout-session`.","tags":["Provisioning"],"security":[{"bearerAuth":[]},{"clientSecretBasic":[]},{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionWorkspaceRequest"},{"$ref":"#/components/schemas/AppWorkspaceRequest"}]}}}},"responses":{"200":{"description":"Idempotent replay of the session branch — the same workspace, `created: false`.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/CreatedWorkspace"}},"required":["data"]}}}},"201":{"description":"Workspace created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/CreatedWorkspace"}},"required":["data"]}}}},"400":{"description":"Malformed body, unknown field, or an unsupported locale","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing, malformed, unknown or suspended client credentials, or an invalid/expired session","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":[],"x-atribu-auth":["app_credential","session"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/workspaces/{workspaceId}/top-performers":{"get":{"summary":"The workspace's top-performing ads, across every profile","description":"The cross-profile creative leaderboard. Ads are scored against *comparable* creative — a cohort is channel × format × objective × audience warmth × geo × placement, and that cohort is populated ACROSS the workspace, which is why this read exists at this grain and cannot be assembled from per-profile calls.\n\n**Three distinct measures per ad. Present them separately; never merge them.** `composite_score` (0–100) is a transparent rule blend of within-cohort percentiles. `top_performer_likelihood` (0–1) is a PROBABILITY — a likelihood, not a guarantee, and never ROAS; it is the calibrated ML-ranker output when `score_source='model'` (with `model_version` set) and a monotone function of `composite_score` otherwise. `attributed_revenue`/`roas` is real cash Atribu attributed, present when `truth_grade='attributed'`. `meta_reported_conversions` is a FOURTH thing again — Meta's own last-click count, never folded into `truth_grade` and never added to Atribu's outcomes.\n\n`primary_outcome_kind` is the cohort-appropriate HEADLINE: `cash` → revenue/ROAS, `pipeline` → attributed leads + `attributed_pipeline_value` (a projection, never in ROAS), `messaging` → conversations started, `meta` → Meta's own number (labelled as such), `none` → the composite score alone. Filter on it with `outcome_kinds` rather than sorting a lead-gen workspace by a ROAS that is null on every row.\n\n`maturity_stage` (cold→early→mature→calibrated) says how settled a score is; `reason_codes` say which funnel layer is strong or weak, with a sample-confidence band and the `sample_n` that band was derived from. Scores refresh on a daily cadence.\n\n**The five nested groups** carry the rest of the feature-store row: `identity` (external ids, creative copy, the delivery days and the attribution model the `attributed_*` legs were credited under), `metrics` (`delivery` / `video` / `messaging` / `outcomes` — sub-grouped because an all-null `video` means 'not a video ad' while an all-null `forecast` means 'not scored yet', and flattening loses that), `score` (the within-cohort `pct_*` percentiles and the empirical-Bayes `smoothed_*` estimates the blend consumes), `creative` (what the ad is made of, with `has_vision_analysis` as the honesty flag over the rest) and `forecast` (the impressions family; the outcomes family stays top-level).\n\nFour things are deliberately NOT published: the ML ranker's per-feature contributions, the raw score-explanation blob, the ten empirical-Bayes effective sample sizes (an input to the smoothing, not a statement about the ad — the smoothed estimate is here and is what can be acted on), and the cohort classifier's `*_source`/`*_confidence` provenance, which is about our pipeline rather than about the advertiser's ad.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\nA workspace owner, admin or analyst sees every profile in the workspace; anyone else sees only the profiles they hold a membership on. That narrowing happens in the database, not in the response shape, so a guest gets a 200 over a smaller set — never someone else's rows.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants."},"required":true,"description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants.","name":"workspaceId","in":"path"},{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"],"description":"A rolling LOOKBACK, not a date range: `creative_feature_store` holds one row per (profile × ad × window), so these four are the only windows that exist. Default `28d`."},"required":false,"description":"A rolling LOOKBACK, not a date range: `creative_feature_store` holds one row per (profile × ad × window), so these four are the only windows that exist. Default `28d`.","name":"window","in":"query"},{"schema":{"type":"string","description":"1–100. Default 50."},"required":false,"description":"1–100. Default 50.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Comma-separated profile uuids to narrow to, within what you can see."},"required":false,"description":"Comma-separated profile uuids to narrow to, within what you can see.","name":"profile_ids","in":"query"},{"schema":{"type":"string","description":"Comma-separated cohort objectives (`messaging`, `sales`, `leads`, …)."},"required":false,"description":"Comma-separated cohort objectives (`messaging`, `sales`, `leads`, …).","name":"objectives","in":"query"},{"schema":{"type":"string","description":"Comma-separated: `predicted` | `attributed` | `lift`. `attributed` means the ad has at least one Atribu-attributed outcome of any revenue type."},"required":false,"description":"Comma-separated: `predicted` | `attributed` | `lift`. `attributed` means the ad has at least one Atribu-attributed outcome of any revenue type.","name":"truth_grades","in":"query"},{"schema":{"type":"string","description":"Comma-separated `primary_outcome_kind` values: `cash` | `pipeline` | `messaging` | `meta` | `none`."},"required":false,"description":"Comma-separated `primary_outcome_kind` values: `cash` | `pipeline` | `messaging` | `meta` | `none`.","name":"outcome_kinds","in":"query"},{"schema":{"type":"string"},"required":false,"name":"formats","in":"query"},{"schema":{"type":"string"},"required":false,"name":"maturity_stages","in":"query"},{"schema":{"type":"string","description":"Comma-separated predicted hazard tiers: `low`|`medium`|`high`|`critical`."},"required":false,"description":"Comma-separated predicted hazard tiers: `low`|`medium`|`high`|`critical`.","name":"fatigue_risk_tiers","in":"query"},{"schema":{"type":"string","description":"Comma-separated OBSERVED states: `active`|`paused`|`degraded`."},"required":false,"description":"Comma-separated OBSERVED states: `active`|`paused`|`degraded`.","name":"fatigue_states","in":"query"},{"schema":{"type":"string"},"required":false,"name":"min_spend","in":"query"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"has_video","in":"query"},{"schema":{"type":"string","description":"Comma-separated opt-in blocks. The only recognised value is `internals`, which attaches an UNSTABLE, non-contract `internals` object to every row (today: `composite_score_breakdown`, the per-layer contributions behind `composite_score`). It is honoured for SESSION credentials only — Atribu's own console — and silently omitted for any other credential rather than refused, so the response degrades to exactly the published shape. An UNRECOGNISED value is a 400: a typo must not look like a capability this deployment lacks."},"required":false,"description":"Comma-separated opt-in blocks. The only recognised value is `internals`, which attaches an UNSTABLE, non-contract `internals` object to every row (today: `composite_score_breakdown`, the per-layer contributions behind `composite_score`). It is honoured for SESSION credentials only — Atribu's own console — and silently omitted for any other credential rather than refused, so the response degrades to exactly the published shape. An UNRECOGNISED value is a 400: a typo must not look like a capability this deployment lacks.","name":"include","in":"query"}],"responses":{"200":{"description":"The workspace's top performers","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"ad_external_id":{"type":"string"},"ad_name":{"type":["string","null"]},"campaign_name":{"type":["string","null"]},"ad_set_name":{"type":["string","null"]},"creative_thumbnail_url":{"type":["string","null"]},"video_id":{"type":["string","null"]},"score_window":{"type":"string"},"composite_score":{"type":["number","null"]},"top_performer_likelihood":{"type":["number","null"]},"model_version":{"type":["number","null"]},"score_source":{"type":"string","enum":["model","rules"]},"maturity_stage":{"type":"string"},"truth_grade":{"type":"string"},"primary_outcome_kind":{"type":"string"},"cohort_key":{"type":["string","null"]},"cohort_objective":{"type":["string","null"]},"cohort_format":{"type":["string","null"]},"cohort_audience_warmth":{"type":["string","null"]},"cohort_channel":{"type":["string","null"]},"cohort_geo":{"type":["string","null"]},"cohort_placement":{"type":["string","null"]},"cohort_n_ads":{"type":"number"},"hook_type":{"type":["string","null"]},"creative_format":{"type":["string","null"]},"cta_type":{"type":["string","null"]},"primary_angle":{"type":["string","null"]},"offers":{"type":"array","items":{"type":"string"}},"spend":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"impressions":{"type":"number"},"ctr":{"type":"number"},"cpc":{"type":["number","null"]},"attributed_cash_outcomes":{"type":"integer"},"attributed_revenue":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"roas":{"type":["number","null"]},"cac":{"type":["number","null"]},"attributed_outcomes_total":{"type":"number"},"attributed_pipeline_outcomes":{"type":"number"},"attributed_pipeline_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"meta_reported_conversions":{"type":"number"},"meta_reported_conversion_value":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Exact decimal string (ADR 0018). Unrounded, no thousands separator, no currency symbol — the currency is a separate field. Parse before doing arithmetic, and keep sorting and range filters server-side: a page of decimal strings sorted lexically gives `\"9.00\" > \"10.00\"`, and that failure is silent."},"cost_per_conversation":{"type":["number","null"]},"messaging_conversations_started":{"type":"number"},"first_reply_rate":{"type":["number","null"]},"avg_engagement_score":{"type":["number","null"]},"reason_codes":{"type":"array","items":{"type":"object","properties":{"layer":{"type":"string"},"polarity":{"type":"string","enum":["strength","weakness","forecast","fatigue"]},"percentile":{"type":["number","null"]},"sample_confidence":{"type":"string","enum":["low","medium","high"]},"sample_n":{"type":"number","description":"Cohort ads the percentile was ranked against. A percentile without its sample is a rank presented as a fact; `sample_confidence` is the band derived from this number."},"code":{"type":"string"},"metric_value":{"type":["number","null"]}},"required":["layer","polarity","percentile","sample_confidence","sample_n"]}},"forecast_outcomes_7d":{"type":["number","null"]},"forecast_outcomes_7d_low":{"type":["number","null"]},"forecast_outcomes_7d_high":{"type":["number","null"]},"forecast_confidence":{"type":["string","null"],"enum":["low","medium","high",null]},"hazard_30d_pause":{"type":["number","null"]},"hazard_30d_degradation":{"type":["number","null"]},"fatigue_state":{"type":["string","null"],"enum":["active","paused","degraded",null]},"fatigue_risk_tier":{"type":["string","null"],"enum":["low","medium","high","critical",null]},"expected_lifespan_days":{"type":["number","null"]},"identity":{"type":"object","properties":{"provider":{"type":"string","example":"meta"},"ad_entity_id":{"type":["string","null"]},"campaign_external_id":{"type":["string","null"]},"ad_set_external_id":{"type":["string","null"]},"creative_body":{"type":["string","null"]},"creative_title":{"type":["string","null"]},"day_from":{"type":["string","null"],"description":"First day of DELIVERY inside the window — not the window's nominal start. An ad that ran six days of a 28d window says so here; dividing by 28 instead of by `metrics.delivery.days_active` is wrong about every per-day number drawn from it."},"day_to":{"type":["string","null"]},"attribution_model_used":{"type":["string","null"],"description":"The model the `attributed_*` legs were credited under. Rows scored under different models are not comparable."}},"required":["provider","ad_entity_id","campaign_external_id","ad_set_external_id","creative_body","creative_title","day_from","day_to","attribution_model_used"]},"metrics":{"type":"object","properties":{"delivery":{"type":"object","properties":{"reach":{"type":"number"},"frequency":{"type":"number"},"clicks":{"type":"number"},"days_active":{"type":"number","description":"Days in the window with non-zero delivery — the denominator for any per-day rate."},"spend_reporting":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"Spend in the PROFILE's reporting currency; the top-level `spend` is the ad account's own. They differ whenever an account bills in a currency the profile does not report in, and only this one is meaningful to sum across profiles."}},"required":["reach","frequency","clicks","days_active","spend_reporting"]},"video":{"type":"object","properties":{"video_plays":{"type":"number"},"video_thruplays":{"type":"number"},"thumb_stop_rate":{"type":["number","null"]},"hook_rate":{"type":["number","null"]},"retention_rate":{"type":["number","null"]},"completion_rate":{"type":["number","null"]},"hold_rate":{"type":["number","null"]},"video_avg_time_watched":{"type":["number","null"],"description":"Seconds, not a rate."}},"required":["video_plays","video_thruplays","thumb_stop_rate","hook_rate","retention_rate","completion_rate","hold_rate","video_avg_time_watched"],"description":"Every rate is NULL — never 0 — for an ad with no video. 'No plays to measure' and 'nobody held past three seconds' are opposite findings."},"messaging":{"type":"object","properties":{"messaging_first_reply":{"type":"number"},"messaging_conversations_replied":{"type":"number"},"messaging_user_depth_3":{"type":"number"},"messaging_total_connections":{"type":"number","description":"Distinct people reached on the messaging surface — the rates' denominator."},"reply_rate":{"type":["number","null"]},"depth_3_rate":{"type":["number","null"]},"depth_5_rate":{"type":["number","null"]},"block_rate":{"type":["number","null"],"description":"Share of connections that blocked. The one rate here that is bad when it is high."}},"required":["messaging_first_reply","messaging_conversations_replied","messaging_user_depth_3","messaging_total_connections","reply_rate","depth_3_rate","depth_5_rate","block_rate"],"description":"Conversation DEPTH, which is the messaging funnel's point: a conversation that starts and dies is not an outcome."},"outcomes":{"type":"object","properties":{"outcome_count_first_payment":{"type":"integer"},"outcome_value_first_payment":{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56","description":"The FIRST-PAYMENT subset of `attributed_revenue`, not an addition to it. `roas_first_payment` is the acquisition ROAS a subscription business budgets against."},"roas_first_payment":{"type":["number","null"]},"cac_first_payment":{"type":["number","null"]},"high_quality_touches":{"type":"number"},"low_quality_touches":{"type":"number"}},"required":["outcome_count_first_payment","outcome_value_first_payment","roas_first_payment","cac_first_payment","high_quality_touches","low_quality_touches"]}},"required":["delivery","video","messaging","outcomes"]},"score":{"type":"object","properties":{"creative_fit_score":{"type":["number","null"]},"top_performer_likelihood_calibrated":{"type":["number","null"],"description":"The ML ranker's CALIBRATED probability — non-null only when a model actually scored this row (`score_source = 'model'`). The top-level `top_performer_likelihood` falls back to a monotone squash of `composite_score` when it did not, so this is how a consumer tells 'a model said 0.71' from 'the rule blend implies 0.71'."},"pct_delivery":{"type":["number","null"]},"pct_attention":{"type":["number","null"]},"pct_retention":{"type":["number","null"]},"pct_click_intent":{"type":["number","null"]},"pct_postclick_messaging":{"type":["number","null"]},"pct_attributed_revenue":{"type":["number","null"]},"pct_platform_diagnostics":{"type":["number","null"]},"smoothed_hook_rate":{"type":["number","null"]},"smoothed_hold_rate":{"type":["number","null"]},"smoothed_retention_rate":{"type":["number","null"]},"smoothed_completion_rate":{"type":["number","null"]},"smoothed_ctr":{"type":["number","null"]},"smoothed_first_reply_rate":{"type":["number","null"]},"smoothed_reply_rate":{"type":["number","null"]},"smoothed_depth_3_rate":{"type":["number","null"]},"smoothed_cost_per_conversation":{"type":["number","null"]},"smoothed_roas":{"type":["number","null"]}},"required":["creative_fit_score","top_performer_likelihood_calibrated","pct_delivery","pct_attention","pct_retention","pct_click_intent","pct_postclick_messaging","pct_attributed_revenue","pct_platform_diagnostics","smoothed_hook_rate","smoothed_hold_rate","smoothed_retention_rate","smoothed_completion_rate","smoothed_ctr","smoothed_first_reply_rate","smoothed_reply_rate","smoothed_depth_3_rate","smoothed_cost_per_conversation","smoothed_roas"]},"creative":{"type":"object","properties":{"camera_shot":{"type":["string","null"]},"claim_style":{"type":["string","null"]},"pain_points_addressed":{"type":"array","items":{"type":"string"}},"audience_targeted":{"type":"array","items":{"type":"string"}},"text_density":{"type":["string","null"]},"language_detected":{"type":["string","null"]},"has_video":{"type":"boolean"},"has_vision_analysis":{"type":"boolean","description":"The honesty flag on this whole group. False means the fields were derived from COPY alone, so an absent `camera_shot` is 'not looked at', not 'no camera movement'."},"creative_signature":{"type":["string","null"],"description":"Stable hash of the creative's structural features. Equal signatures mean the same creative — how a duplicate is spotted across profiles."}},"required":["camera_shot","claim_style","pain_points_addressed","audience_targeted","text_density","language_detected","has_video","has_vision_analysis","creative_signature"]},"forecast":{"type":"object","properties":{"forecast_impressions_7d":{"type":["number","null"]},"forecast_impressions_7d_low":{"type":["number","null"]},"forecast_impressions_7d_high":{"type":["number","null"]},"forecast_cost_per_outcome_7d":{"type":["number","null"],"description":"Projected cost per outcome over the next 7 days. A cost-per-unit RATIO (ADR 0018 §3) like `cost_per_conversation`, deliberately NOT monetary: it reconciles against no ledger, and splitting a metric from its own forecast across two wire types is how one quantity ends up with two shapes."}},"required":["forecast_impressions_7d","forecast_impressions_7d_low","forecast_impressions_7d_high","forecast_cost_per_outcome_7d"]},"internals":{"type":"object","properties":{"composite_score_breakdown":{}},"description":"UNSTABLE — NOT PART OF THE PUBLIC CONTRACT. Present only when `include=internals` is requested by a session credential (Atribu's own console); absent otherwise, and the parameter is ignored for any other credential. The shape belongs to the scoring engine and may change or disappear in any release without a version bump. Do not build against it."},"profile_id":{"type":"string","example":"uuid","description":"The profile this ad belongs to. Present because the leaderboard spans profiles — the same `ad_external_id` can legitimately appear under two of them."},"profile_name":{"type":"string","example":"Clinica Norte"},"currency":{"type":["string","null"],"example":"CLP","description":"The profile's reporting currency (ISO-4217). Present on the WORKSPACE leaderboard and not on the profile-scoped one, because this response can carry a CLP profile's ads beside a USD profile's and a renderer has no other way to tell them apart. `null` when it could not be resolved — render the amount uncoded rather than assuming dollars."}},"required":["ad_external_id","ad_name","campaign_name","ad_set_name","creative_thumbnail_url","video_id","score_window","composite_score","top_performer_likelihood","model_version","score_source","maturity_stage","truth_grade","primary_outcome_kind","cohort_key","cohort_objective","cohort_format","cohort_audience_warmth","cohort_channel","cohort_geo","cohort_placement","cohort_n_ads","hook_type","creative_format","cta_type","primary_angle","offers","spend","impressions","ctr","cpc","attributed_cash_outcomes","attributed_revenue","roas","cac","attributed_outcomes_total","attributed_pipeline_outcomes","attributed_pipeline_value","meta_reported_conversions","meta_reported_conversion_value","cost_per_conversation","messaging_conversations_started","first_reply_rate","avg_engagement_score","reason_codes","forecast_outcomes_7d","forecast_outcomes_7d_low","forecast_outcomes_7d_high","forecast_confidence","hazard_30d_pause","hazard_30d_degradation","fatigue_state","fatigue_risk_tier","expected_lifespan_days","identity","metrics","score","creative","forecast","profile_id","profile_name","currency"]}},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid window, limit or filter value","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the campaigns:read scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not a member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/creative-patterns":{"get":{"summary":"Cross-profile creative patterns, and who is not running them","description":"Which (hook type × creative format × CTA type) clusters beat the workspace baseline, one row per cluster, with the profiles running it (`profile_ids`) and — the actionable half — the profiles that are NOT (`gap_profile_ids`).\n\n**The lift is an ASSOCIATION.** `confidence_note` says so on every row, and `roas_ci_low`/`roas_ci_high` are the interval the estimate sits in. Rendering `lift_vs_baseline` without the interval reports a point estimate as a fact.\n\n`min_cluster_size` (3–50, default 3) is an honesty floor, not a page size: below it a cluster's win rate is dominated by its own sample noise. Lowering it does not reveal more patterns, it reveals more coin flips.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\nA workspace owner, admin or analyst sees every profile in the workspace; anyone else sees only the profiles they hold a membership on. That narrowing happens in the database, not in the response shape, so a guest gets a 200 over a smaller set — never someone else's rows.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants."},"required":true,"description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants.","name":"workspaceId","in":"path"},{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"],"description":"A rolling LOOKBACK, not a date range: `creative_feature_store` holds one row per (profile × ad × window), so these four are the only windows that exist. Default `28d`."},"required":false,"description":"A rolling LOOKBACK, not a date range: `creative_feature_store` holds one row per (profile × ad × window), so these four are the only windows that exist. Default `28d`.","name":"window","in":"query"},{"schema":{"type":"string","description":"3–50. Default 3."},"required":false,"description":"3–50. Default 3.","name":"min_cluster_size","in":"query"}],"responses":{"200":{"description":"The workspace's creative patterns","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/CreativePatternCluster"}},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid window or min_cluster_size","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the campaigns:read scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not a member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/profiles":{"get":{"summary":"List the profiles the signed-in user can open in a workspace","description":"The profile switcher's list, ordered by name.\n\nThis is the COLD-BOOT companion to `GET /api/v1/profiles`, which answers the same question but only once a profile is already in context (its `?workspace_id=` is an assertion, not a selector). Both return the same rows, so a client can move between them without reshaping anything.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\nA workspace owner, admin or analyst sees every profile in the workspace; anyone else sees only the profiles they hold a membership on. That narrowing happens in the database, not in the response shape, so a guest gets a 200 over a smaller set — never someone else's rows.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants."},"required":true,"description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"The profiles this user can open in the workspace","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceProfileSummary"}},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not a member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/subscription":{"get":{"summary":"The workspace's current subscription — plan, status, period bounds","description":"`subscriptions`' non-secret columns for this workspace: plan tier, billing interval, status, provider, the current billing period, trial dates and cancellation state, plus the workspace's `active_profiles` usage against its plan limit. `data: null` (not a 404) when the workspace has no `subscriptions` row at all — a real state for a workspace that has never started a plan.\n\nDeliberately narrower than the full row: no Stripe/Shopify ids, no email-tracking timestamps. `cancel_at` (a live Stripe `Subscription` field) is not served here — only the stored `cancel_at_period_end` boolean is.\n\n`limits.active_profiles` (#1067) is the exception to \"no entitlement amounts\": `used`/`max` are the same numbers `POST /api/v1/profiles` compares before answering `403 profile_limit_reached`, so an agent sees the limit coming.\n\n`upgrade_available` (#1052) names the plans above this one and the route that mints a Stripe Checkout hand-off for each, so an agent can tell a human WHY before asking them to pay rather than discovering the ceiling at a denial.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants."},"required":true,"description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"The workspace's subscription, or null when it has none","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"allOf":[{"$ref":"#/components/schemas/WorkspaceSubscription"},{"type":["object","null"]}]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or you are not an active member","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}":{"delete":{"summary":"Archive a workspace","description":"Retires a workspace and everything under it. **OWNER ONLY**, and **terminal — there is no un-archive.**\n\n**\"Delete\" here means ARCHIVE, and the response says so.** A workspace that has ever exported holds `conversion_exports` rows, which a database trigger refuses to let any path erase — they are the record of what Atribu transmitted on a customer's behalf. So this call stamps `archived_at`, sets every membership to `removed`, revokes pending invitations, archives every profile and purges their non-audit data, revokes the workspace's API keys and pauses its webhook subscriptions; `conversion_exports` and `tracking_signal_audit_log` stay attached to the archived workspace and are counted in `counts.retained_*`. Same contract as `DELETE /api/v1/profiles/demo`.\n\n**Idempotent.** The first call answers `200 {\"archived\": true}`; every later call answers `200 {\"archived\": false}` with the ORIGINAL `archived_at`. A DELETE that 404s an already-archived workspace would make a retry look like a failure.\n\n**Credentials.** A session bearer, or an MCP `atb_user_…` token whose grant covers `workspaces:write` (a default `mcp:read` token is refused). An `atb_live_` API key cannot call it — a key is minted for exactly one profile and belongs to no workspace. A member who is not an owner gets `403 forbidden`; a non-member gets `404`, the same answer an unknown id gets, so the status code never confirms that a workspace exists.\n\n**After archiving, every workspace-scoped route answers 404 for it**, and it is absent from `GET /api/v1/workspaces` and from MCP `whoami`.","tags":["Provisioning"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to archive. You must be its owner."},"required":true,"description":"The workspace to archive. You must be its owner.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"Archived, or already archived — `archived` says which, and both are successes.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ArchivedWorkspace"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"`workspaceId` is not a uuid","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Missing, invalid or expired credential","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"A member who is not an owner, an API key, or an MCP token without `workspaces:write`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such workspace, or the caller is not a member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":[],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"}},"/api/v1/workspaces/{workspaceId}/creative-patterns/dimensions":{"get":{"summary":"Which single creative-dimension values win in this workspace","description":"The DIMENSION-grain companion to `/creative-patterns`. That route mines (hook × format × cta) COMBINATIONS and tells you who is not running them; this one takes one dimension at a time — hook type, narrative arc, CTA and so on — and answers how often an ad carrying a given value ends up a winner.\n\n**The lift is an ASSOCIATION.** `lift_vs_workspace` is the row's win rate over the workspace's own baseline, and `confidence_interval_low`/`_high` is the interval that estimate sits in. Rendering the lift without the interval reports a point estimate as a fact — and neither is a causal claim. The only causal evidence on this surface is an experiment (`/experiments`).\n\n`min_cluster_size` (3–50, default 3) is an honesty floor, not a page size: below it a win rate is dominated by its own sample noise. Lowering it does not reveal more patterns, it reveals more coin flips.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\nA workspace owner, admin or analyst sees every profile in the workspace; anyone else sees only the profiles they hold a membership on. That narrowing happens in the database, not in the response shape, so a guest gets a 200 over a smaller set — never someone else's rows.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants."},"required":true,"description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants.","name":"workspaceId","in":"path"},{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"],"description":"A rolling LOOKBACK, not a date range: `creative_feature_store` holds one row per (profile × ad × window), so these four are the only windows that exist. Default `28d`."},"required":false,"description":"A rolling LOOKBACK, not a date range: `creative_feature_store` holds one row per (profile × ad × window), so these four are the only windows that exist. Default `28d`.","name":"window","in":"query"},{"schema":{"type":"string","description":"3–50. Default 3."},"required":false,"description":"3–50. Default 3.","name":"min_cluster_size","in":"query"}],"responses":{"200":{"description":"Winning creative-dimension values","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceCreativePatternDimension"}},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid window or min_cluster_size","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the campaigns:read scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not a member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/creative-archetypes":{"get":{"summary":"The workspace's creative archetypes and how often each one wins","description":"An archetype is a learned CLUSTER over the creative fingerprint, so `label`, `description` and `winning_signal` describe the cluster rather than a query anyone wrote. `archetype_cluster` is its opaque id: stable inside a workspace, meaningless across one.\n\n**The lift is an ASSOCIATION.** `lift_vs_workspace` is the row's win rate over the workspace's own baseline, and `confidence_interval_low`/`_high` is the interval that estimate sits in. Rendering the lift without the interval reports a point estimate as a fact — and neither is a causal claim. The only causal evidence on this surface is an experiment (`/experiments`).\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\nA workspace owner, admin or analyst sees every profile in the workspace; anyone else sees only the profiles they hold a membership on. That narrowing happens in the database, not in the response shape, so a guest gets a 200 over a smaller set — never someone else's rows.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants."},"required":true,"description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants.","name":"workspaceId","in":"path"},{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"],"description":"A rolling LOOKBACK, not a date range: `creative_feature_store` holds one row per (profile × ad × window), so these four are the only windows that exist. Default `28d`."},"required":false,"description":"A rolling LOOKBACK, not a date range: `creative_feature_store` holds one row per (profile × ad × window), so these four are the only windows that exist. Default `28d`.","name":"window","in":"query"}],"responses":{"200":{"description":"The workspace's creative archetypes","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceCreativeArchetype"}},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid window","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the campaigns:read scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not a member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/experiments":{"get":{"summary":"Meta lift and split studies across the workspace's profiles","description":"The only rows on this surface backed by an EXPERIMENT rather than a score. `primary_incremental_conversions` is Meta's estimate of conversions that would not have happened without the ad — a COUNT, with its own interval and confidence level. It is deliberately not called a 'lift value': the underlying column is a conversion count, and a name containing `value` reads as money.\n\n`primary_winner_cell_name` comes from a different result row (the best split comparison), so a study can carry a winner with no incremental estimate or the reverse. They are not two views of one number.\n\n**No `window` parameter.** An experiment has its own start and end and is not a slice of a rolling lookback; filtering by one would hide the study that ran last quarter and is the workspace's only causal evidence.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\nA workspace owner, admin or analyst sees every profile in the workspace; anyone else sees only the profiles they hold a membership on. That narrowing happens in the database, not in the response shape, so a guest gets a 200 over a smaller set — never someone else's rows.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants."},"required":true,"description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"The workspace's experiments","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceExperiment"}},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the campaigns:read scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not a member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/test-roadmap":{"get":{"summary":"What each profile should test next, and how much creative it has to judge on","description":"`gaps` are patterns proven somewhere in the workspace that a profile is not running, each with the evidence behind it and a concrete `exemplar_ad_external_id` so the suggestion can be checked rather than trusted.\n\n**`coverage` is not optional context — it is what makes an empty `gaps` readable.** No gaps means one of two opposite things: this profile already does everything that works, or it has four scored ads and nothing can be concluded. Only `scored_ad_count` separates them, which is why both ride in one response.\n\n**The lift is an ASSOCIATION.** `lift_vs_workspace` is the row's win rate over the workspace's own baseline, and `confidence_interval_low`/`_high` is the interval that estimate sits in. Rendering the lift without the interval reports a point estimate as a fact — and neither is a causal claim. The only causal evidence on this surface is an experiment (`/experiments`).\n\nA gap's `exemplar_composite_score` (a 0–100 rule blend) and `exemplar_roas` (a ratio over attributed cash) are separate fields and must stay so — one is a score, the other is money divided by money.\n\n`test_key` is the stable id to record a decision against (see `POST /creative-tests/decisions`); the exemplar ad rotates on every rebuild and must never be used as a key.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\nA workspace owner, admin or analyst sees every profile in the workspace; anyone else sees only the profiles they hold a membership on. That narrowing happens in the database, not in the response shape, so a guest gets a 200 over a smaller set — never someone else's rows.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants."},"required":true,"description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants.","name":"workspaceId","in":"path"},{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"],"description":"A rolling LOOKBACK, not a date range: `creative_feature_store` holds one row per (profile × ad × window), so these four are the only windows that exist. Default `28d`."},"required":false,"description":"A rolling LOOKBACK, not a date range: `creative_feature_store` holds one row per (profile × ad × window), so these four are the only windows that exist. Default `28d`.","name":"window","in":"query"},{"schema":{"type":"string","description":"Gaps per PROFILE, 1–20. Default 4."},"required":false,"description":"Gaps per PROFILE, 1–20. Default 4.","name":"per_profile_limit","in":"query"}],"responses":{"200":{"description":"The testing roadmap and its coverage","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WorkspaceTestRoadmap"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid window or per_profile_limit","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the campaigns:read scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not a member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/top-performers/brief":{"get":{"summary":"The six counters that summarise a workspace's leaderboard","description":"A one-row summary of the SAME window `/top-performers` reads. None of the six is a lifetime total: `new_winners_count` is new *in this window* and `at_risk_count` is ads whose predicted hazard crossed a tier *in this window*, so rendering them beside a lifetime figure compares two different questions.\n\n`lift_backed_count` is the one to read first — how many ads have an EXPERIMENT behind them rather than a score. It is normally small, and that is the true state.\n\n`open_recs_value_dollars` is a forecast (the summed `expected_impact_dollars` of the open recommendations), never realised cash.\n\nA workspace with nothing scored answers 200 with every counter at zero. A FAILED read answers 5xx — the two are never the same response.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\nA workspace owner, admin or analyst sees every profile in the workspace; anyone else sees only the profiles they hold a membership on. That narrowing happens in the database, not in the response shape, so a guest gets a 200 over a smaller set — never someone else's rows.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants."},"required":true,"description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants.","name":"workspaceId","in":"path"},{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"],"description":"A rolling LOOKBACK, not a date range: `creative_feature_store` holds one row per (profile × ad × window), so these four are the only windows that exist. Default `28d`."},"required":false,"description":"A rolling LOOKBACK, not a date range: `creative_feature_store` holds one row per (profile × ad × window), so these four are the only windows that exist. Default `28d`.","name":"window","in":"query"}],"responses":{"200":{"description":"The workspace's leaderboard brief","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WorkspaceTopPerformersBrief"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid window","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the campaigns:read scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not a member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/tab-counts":{"get":{"summary":"How much is new on each console tab since you last opened it","description":"Counted against the CALLER's own last-visit watermark, so two members of the same workspace correctly get different numbers. This is the one read on this surface whose answer is about you rather than merely narrowed by you — do not cache it under a key that names only the workspace.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants."},"required":true,"description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"Fresh-since-last-visit counts per tab","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceTabCount"}},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not a member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/pacing":{"get":{"summary":"Month-to-date spend and outcomes against budget, per profile","description":"One row per profile the caller can open.\n\n**`pace_spend_pct` is not percent-of-budget-spent.** It is spend so far over the budget PRO-RATED to `days_elapsed / days_in_month`, so `1.0` means on track today and `1.4` means 40% ahead of plan. Both pace fields are NULL when the profile has no budget or goal set — a different state from `0`, and the one where there is nothing to be on track against.\n\nEvery amount on a row is in that row's `currency`; profiles in one workspace do not have to share one, so these must not be summed without converting.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\nA workspace owner, admin or analyst sees every profile in the workspace; anyone else sees only the profiles they hold a membership on. That narrowing happens in the database, not in the response shape, so a guest gets a 200 over a smaller set — never someone else's rows.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants."},"required":true,"description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"Per-profile pacing","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspacePacing"}},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not a member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/recommendations":{"get":{"summary":"Open recommendations across every profile in the workspace","description":"The workspace-grain twin of `GET /api/v1/recommendations`, which stays: a key holder has exactly one profile, and asking it for a workspace's recommendations is a different question. Both return the same per-row shape.\n\n**Read the calibration fields.** `expected_impact_dollars` is a forecast made when the recommendation was written; `realized_impact_dollars` is what actually happened afterwards and `calibration_error` is the gap. A surface that shows only the forecast is reporting a hope as a result. Both are NULL until measured, which is not zero.\n\nApplying or dismissing one is `POST /api/v1/recommendations/{id}/apply` / `/dismiss` — profile-scoped, because a money move names one profile.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\nA workspace owner, admin or analyst sees every profile in the workspace; anyone else sees only the profiles they hold a membership on. That narrowing happens in the database, not in the response shape, so a guest gets a 200 over a smaller set — never someone else's rows.","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants."},"required":true,"description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants.","name":"workspaceId","in":"path"},{"schema":{"type":"string","enum":["7d","14d","28d","lifetime"],"description":"A rolling LOOKBACK, not a date range: `creative_feature_store` holds one row per (profile × ad × window), so these four are the only windows that exist. Default `28d`."},"required":false,"description":"A rolling LOOKBACK, not a date range: `creative_feature_store` holds one row per (profile × ad × window), so these four are the only windows that exist. Default `28d`.","name":"window","in":"query"},{"schema":{"type":"string","description":"1–100. Default 20."},"required":false,"description":"1–100. Default 20.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Comma-separated profile uuids, within what you can see."},"required":false,"description":"Comma-separated profile uuids, within what you can see.","name":"profile_ids","in":"query"},{"schema":{"type":"string"},"required":false,"name":"kinds","in":"query"},{"schema":{"type":"string","description":"Comma-separated: `open` | `applied` | `dismissed` | `expired`."},"required":false,"description":"Comma-separated: `open` | `applied` | `dismissed` | `expired`.","name":"statuses","in":"query"},{"schema":{"type":"string"},"required":false,"name":"risk_tiers","in":"query"},{"schema":{"type":"string"},"required":false,"name":"cohort_objective","in":"query"}],"responses":{"200":{"description":"The workspace's recommendations","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceRecommendation"}},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid window or limit","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the campaigns:read scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not a member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/replicate-runs/{runId}":{"get":{"summary":"One Replicate run — a winning ad adapted for another profile","description":"The deep link behind a 'your draft is ready' notification.\n\nA run names TWO profiles, and BOTH must be ones you can open: a member who holds the target but not the source must not learn the source ad's name, which is exactly what the run carries. A run that does not exist, one in another workspace, and one whose profiles you cannot open all answer the SAME 404, so the status code cannot be used to probe for runs.\n\n`fit`, `brief`, `variants`, `scene_rail`, `expected_band`, `brand_guard`, `hook_candidates`, `extract_payload` and `research_payload` are OPAQUE — whatever the generating pass wrote. Render what you recognise, never compute from them.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"The run","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WorkspaceReplicateRun"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed workspaceId or runId","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the campaigns:read scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The run does not exist, is in another workspace, or names a profile you cannot open","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/replicate-runs/{runId}/handoff":{"post":{"summary":"Bind a finished Replicate run to the creative session it was handed to","description":"Call this once you have created the chat/brief session an operator will read the draft in, so the run stops advertising itself as ready to send and the deep link resolves to that session.\n\n**Idempotent on the RUN, without an `Idempotency-Key`.** A run carries at most one `ads_lab_session_id`; a second handoff answers 200 with the FIRST one and `already_handed_off: true`, and your id is not stored. Re-pointing the run would orphan a session an operator may already have open.\n\n**A compare-and-set from `review`.** A run that is not in `review` and does not already carry a session answers 409 — a cancel that raced the send must not come back as `sent`.\n\nThe same 404 the GET gives: a run that does not exist, one in another workspace, and one whose two profiles you cannot both open are indistinguishable.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"runId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ads_lab_session_id":{"type":"string","format":"uuid","description":"The session you created for this draft."}},"required":["ads_lab_session_id"]}}}},"responses":{"200":{"description":"The run, now bound to a session","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WorkspaceReplicateHandoff"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed workspaceId, runId or ads_lab_session_id","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the campaigns:apply scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The run does not exist, is in another workspace, or names a profile you cannot open","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"The run is not in `review` and carries no session — its status moved underneath you","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:apply"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/creative-tests/decisions":{"post":{"summary":"Dismiss or draft one testing-roadmap gap","description":"The roadmap is generated on read; this records only the DECISIONS, keyed on `(workspace, profile, score_window, test_key)`. Never key a decision on the exemplar ad — it rotates on every rebuild, and the decision must survive that.\n\nOne route for both decisions because they are one row: a `drafted` may flip a prior `dismissed` and vice versa. Each decision only touches its OWN columns, so re-deciding never erases the other's history.\n\n**Idempotent.** Repeating a decision is a 200 with the same body, not a 409 — a retry after a dropped response must not read as an error.\n\n`drafted_run_id` is only valid with `decision=drafted`; sending it with a dismissal is a 400 rather than a silently stored link the status contradicts.\n\nA `profile_id` you cannot open answers 404, identical to one that does not exist.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants."},"required":true,"description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants.","name":"workspaceId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"profile_id":{"type":"string","format":"uuid"},"test_key":{"type":"string"},"kind":{"type":"string","enum":["cohort","archetype","format"]},"target_dimension":{"type":"string"},"target_value":{"type":"string"},"decision":{"type":"string","enum":["dismissed","drafted"]},"score_window":{"type":"string","enum":["7d","14d","28d","lifetime"],"description":"Default `28d`. Part of the decision's key."},"drafted_run_id":{"type":"string","format":"uuid","description":"Only with `decision=drafted`."}},"required":["profile_id","test_key","kind","target_dimension","target_value","decision"]}}}},"responses":{"200":{"description":"The decision now recorded","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/CreativeTestDecision"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing or invalid body field","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the campaigns:apply scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace or the named profile does not exist, or you cannot open it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:apply"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/experiment-promotions":{"post":{"summary":"Record an ad's promotion to a Meta experiment","description":"Call this BEFORE handing the operator off to Ads Manager, so the draft survives them never coming back.\n\n**Idempotent without an `Idempotency-Key`: the key is the ad.** One promotion per `(workspace, profile, ad_external_id)`, and re-posting returns the same `promotion_id`.\n\n**It never regresses a lifecycle.** If the reconciliation sync has already advanced the promotion to `live` or `linked`, the existing row is left exactly as it is, your draft is NOT stored, and the response says so in `advanced_beyond_write`. A client that ignores that flag tells an operator their draft was saved when it was not.\n\n`draft_config` and `draft_payload` are stored verbatim and are opaque to this API. `create_job_enqueued` reports whether the direct-create job was queued — it is server-flag gated and non-fatal, so `false` never means the promotion failed.\n\nA `profile_id` you cannot open answers 404, identical to one that does not exist.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to read across and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Campaigns"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants."},"required":true,"description":"The workspace to read. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets, so the status code never becomes a way to enumerate tenants.","name":"workspaceId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"profile_id":{"type":"string","format":"uuid"},"ad_external_id":{"type":"string"},"ad_name":{"type":["string","null"]},"adset_external_id":{"type":["string","null"]},"campaign_external_id":{"type":["string","null"]},"meta_ad_account_id":{"type":["string","null"]},"deep_link_url":{"type":["string","null"]},"draft_config":{"type":"object","additionalProperties":{}},"draft_payload":{"type":"object","additionalProperties":{}},"mode":{"type":"string","enum":["handed_off","submitted"],"description":"Default `handed_off`. `submitted` only when a direct create actually fired."}},"required":["profile_id","ad_external_id","draft_config","draft_payload"]}}}},"responses":{"200":{"description":"The recorded promotion","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AdExperimentPromotion"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing or invalid body field","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the campaigns:apply scope, or the credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace or the named profile does not exist, or you cannot open it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["campaigns:apply"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/me":{"get":{"summary":"The signed-in user's own account","description":"Who the caller is, how they can sign in, where they are signed in, and what would block them from deleting the account — in one round trip, because a settings surface wants all four at once.\n\n`user` is the editable identity (`PATCH /api/v1/me` changes `full_name` and `locale`). `identities` is the auth-provider list — provider keys and the address each provider knows, never a token. `sessions` is one row per signed-in device, freshest first, with `is_current` resolved from the bearer token this request carried. `sole_owner_workspaces` names the workspaces whose ownership would be orphaned by a deletion.\n\n**App credentials get a different, smaller answer (#1048).** An OAuth app's own HTTP Basic `client_id:client_secret` — the credential `POST /api/v1/profiles` and `POST /api/v1/workspaces` accept — receives `{ data: { app, return_url_supported } }` with no `meta`, since the caller is a credential, not a person. `return_url_supported` is the one fact a consumer needs before passing a `return_url` to any connect start.\n\nEverything here is the CALLER'S OWN data, resolved from the verified token and from nothing the request asserted. There is no parameter naming a user, so reading someone else's account is not expressible — which is also why no PII scope gates it.\n\n**Session bearers only.** An API key is minted for exactly one profile — it is not a person, has no sessions and no linked identities — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]},{"clientSecretBasic":[]}],"responses":{"200":{"description":"The caller's account (session bearer), or `{ data: { app, return_url_supported } }` for an app credential","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Me"},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]},{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MeApp"}},"required":["data"]}]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":[],"x-atribu-auth":["app_credential","session"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"patch":{"summary":"Update the signed-in user's display name or locale","description":"A partial update: omit a field to leave it alone. Both fields live in Atribu's own `user_profiles`, so neither triggers a Supabase Auth flow and neither sends mail.\n\n**Email and password are NOT here** — see `POST /api/v1/me/password` and the `/api/v1/me/email` pair below. Each is a real GoTrue flow (a breach check and a rate-limited re-authentication for the password; a dual-confirmation mail for the email) with its own request shape, so neither fits a partial PATCH of two plain columns.\n\nReturns the updated `user` object — the same shape `GET /api/v1/me` nests under `user`.\n\n**Session bearers only.** An API key is minted for exactly one profile — it is not a person, has no sessions and no linked identities — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MePatch"}}}},"responses":{"200":{"description":"The updated user","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MeUser"},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"400":{"description":"An empty name, an unsupported locale, or a body naming no field","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"},"delete":{"summary":"Irreversibly delete the caller's account","description":"Three steps, server-side: re-check `sole_owner_workspaces` FRESH (a client's own copy may be stale), null out audit actor references and remove workspace memberships, then delete the GoTrue user and every linked identity.\n\n**`deleted: false` is not an error.** It means the caller is the sole active owner of at least one workspace; `blockers` is that same list. Transfer ownership of each, then retry — this is the same treatment `GET /api/v1/me`'s `sole_owner_workspaces` already gets, now the thing that actually enforces it.\n\nNo rollback: a failure between the two write steps leaves the account partially prepared, the same gap the internal route this replaces already had.\n\n**Session bearers only.** An API key is minted for exactly one profile — it is not a person, has no sessions and no linked identities — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Either the account was deleted, or it was blocked — see `data.deleted`","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MeDeleteResult"},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"}},"/api/v1/me/sessions":{"delete":{"summary":"Sign out every other device","description":"Revokes every session the caller holds EXCEPT the one this request was made with — which is identified from the bearer token's own `session_id` claim, so a client cannot accidentally sign itself out and cannot ask to keep a session that is not its own.\n\nReturns what is left: the caller's remaining sessions, freshest first. That is a fact the response actually reads, unlike a bare `revoked: true` — and it saves the round trip a client would otherwise make to redraw the list.\n\n**Session bearers only.** An API key is minted for exactly one profile — it is not a person, has no sessions and no linked identities — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The sessions that remain (at least the caller's own)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MeSession"}},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"}},"/api/v1/me/sessions/{sessionId}":{"delete":{"summary":"Revoke one session","description":"Signs one device out. The revoke is scoped to the caller's own sessions in SQL, so a session id belonging to someone else deletes nothing — and answers exactly as a session id that never existed does, so the response is not a way to test whether an id is real.\n\nRevoking the CURRENT session is permitted and means signing yourself out; `is_current` on `GET /api/v1/me` is how a UI decides whether to offer it.\n\nReturns the caller's remaining sessions, freshest first.\n\n**Session bearers only.** An API key is minted for exactly one profile — it is not a person, has no sessions and no linked identities — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"An `id` from `GET /api/v1/me`'s `sessions`."},"required":true,"description":"An `id` from `GET /api/v1/me`'s `sessions`.","name":"sessionId","in":"path"}],"responses":{"200":{"description":"The sessions that remain","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MeSession"}},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"400":{"description":"The session id is not a uuid","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"}},"/api/v1/me/password":{"post":{"summary":"Change the caller's password","description":"Rejects a password shorter than 8 characters or one that appears in a public breach corpus (HaveIBeenPwned k-anonymity check — only a 5-character SHA-1 prefix ever leaves this process; the check fails OPEN on a HIBP outage). When the caller has an email/password identity, `current_password` is REQUIRED and is verified before the change; an OAuth-only caller (setting a password for the first time) may omit it.\n\nRate-limited per-user AND per-IP.\n\n**Session bearers only.** An API key is minted for exactly one profile — it is not a person, has no sessions and no linked identities — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMePasswordBody"}}}},"responses":{"200":{"description":"Password changed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Ok"},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"400":{"description":"`current_password` is required (and was omitted), or was wrong","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"The new password is too short, or appears in a public breach corpus","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"}},"/api/v1/me/email":{"post":{"summary":"Start an email change","description":"Triggers GoTrue's dual-confirmation flow: a security-alert link to the OLD address and a confirm link to the NEW one. The change only completes once the new address confirms — until then `GET /api/v1/me`'s `user.pending_email` shows it, `user.email` still shows the old address.\n\nRate-limited per-user AND per-IP (a lower cap than password: each attempt costs GoTrue two sends).\n\n**Session bearers only.** An API key is minted for exactly one profile — it is not a person, has no sessions and no linked identities — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestMeEmailChangeBody"}}}},"responses":{"200":{"description":"Confirmation mail sent","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Ok"},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"400":{"description":"Not a valid email address, or it matches the current one","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"},"delete":{"summary":"Cancel a pending email change","description":"Clears `user.pending_email`. GoTrue has no first-class \"cancel\" call; this resets `email` to its own current value through the admin API, which is the supported way to null out the pending change.\n\n**Session bearers only.** An API key is minted for exactly one profile — it is not a person, has no sessions and no linked identities — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Pending change cleared","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Ok"},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"}},"/api/v1/me/mcp-tokens":{"get":{"summary":"The caller's MCP tokens","description":"Every MCP token the signed-in user has minted, prefix only — never the secret, which is shown exactly once at creation.\n\n**Session bearers only.** An API key is minted for exactly one profile — it is not a person, has no MCP credentials of its own — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The caller's MCP tokens","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/McpToken"}},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"},"post":{"summary":"Mint a new MCP token","description":"Capped at 10 active tokens per user. Returns the plaintext secret exactly once — save it now, it will not be shown again.\n\n**Session bearers only.** An API key is minted for exactly one profile — it is not a person, has no MCP credentials of its own — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateMcpTokenBody"}}}},"responses":{"201":{"description":"The new token, including its one-time plaintext secret","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/McpTokenCreated"},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"400":{"description":"An invalid scope, or the 10-token cap is already reached","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"}},"/api/v1/me/mcp-tokens/{tokenId}/revoke":{"post":{"summary":"Revoke one MCP token","description":"Scoped to the caller's own tokens — a `tokenId` belonging to someone else answers the same 404 a nonexistent one does.\n\n**Session bearers only.** An API key is minted for exactly one profile — it is not a person, has no MCP credentials of its own — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"tokenId","in":"path"}],"responses":{"200":{"description":"The token's new state","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/McpToken"},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Token not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"}},"/api/v1/me/mcp-tokens/{tokenId}/rotate":{"post":{"summary":"Rotate one MCP token","description":"Mints a new secret and starts a 48-hour grace window in which the OLD token keeps working, so an already-configured MCP client does not lose access mid-swap. Same one-time plaintext contract as create.\n\n**Session bearers only.** An API key is minted for exactly one profile — it is not a person, has no MCP credentials of its own — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"tokenId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateMcpTokenBody"}}}},"responses":{"201":{"description":"The new token, including its one-time plaintext secret","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/McpTokenCreated"},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"400":{"description":"Only ACTIVE tokens can be rotated","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Token not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"}},"/api/v1/me/mcp-oauth-grants":{"get":{"summary":"The caller's connected MCP apps","description":"Every MCP OAuth client the signed-in user has authorized and not revoked — the \"connected apps\" list.\n\n**Session bearers only.** An API key is minted for exactly one profile — it is not a person, has no MCP credentials of its own — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The caller's connected apps","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/McpOauthGrant"}},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"}},"/api/v1/me/mcp-oauth-grants/{id}/revoke":{"post":{"summary":"Disconnect an MCP app","description":"Revokes the grant and every access token minted under it; the connected client loses access immediately.\n\n**Session bearers only.** An API key is minted for exactly one profile — it is not a person, has no MCP credentials of its own — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Disconnected","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Grant not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"}},"/api/v1/me/mcp-usage":{"get":{"summary":"The caller's current-period MCP usage","description":"Units used against the cap, summed across every workspace the caller belongs to, at the highest-cap ACTIVE subscription's tier — the same resolution the MCP server itself uses.\n\n**Session bearers only.** An API key is minted for exactly one profile — it is not a person, has no MCP credentials of its own — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The caller's usage this period","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/McpUsage"},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"}},"/api/v1/workspaces/{workspaceId}/api-keys":{"get":{"summary":"A profile's API keys","description":"Every `atb_live_` key minted for one profile in this workspace, prefix only — never the secret, which is shown exactly once at creation. `profile_id` is REQUIRED and must name a profile the caller can open in this workspace; one they cannot answers 404, identical to a profile that does not exist.\n\n**Session bearers only.** An API key is minted for exactly one profile — it cannot mint, revoke or rotate a sibling key — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"profile_id","in":"query"}],"responses":{"200":{"description":"The profile's API keys","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace, or the named profile within it, was not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"},"post":{"summary":"Mint a new API key for a profile","description":"Capped at 10 active keys per profile. Returns the plaintext `atb_live_...` secret exactly once — save it now, it will not be shown again.\n\n**Session bearers only.** An API key is minted for exactly one profile — it cannot mint, revoke or rotate a sibling key — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"workspaceId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyBody"}}}},"responses":{"201":{"description":"The new key, including its one-time plaintext secret","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiKeyCreated"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"An invalid scope, or the 10-key cap is already reached","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace, or the named profile within it, was not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["tracking:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/api-keys/{id}/revoke":{"post":{"summary":"Revoke one API key","description":"The key must belong to a profile in THIS workspace, one the caller can open; either mismatch answers the same 404 an id that never existed does.\n\n**Session bearers only.** An API key is minted for exactly one profile — it cannot mint, revoke or rotate a sibling key — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The key's new state","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiKeyRevoked"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"The key is already revoked","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Key not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["tracking:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/api-keys/{id}/rotate":{"post":{"summary":"Rotate one API key","description":"Mints a new secret and starts a 48-hour grace window in which the OLD key keeps working, so an already-configured integration does not lose access mid-swap. Same one-time plaintext contract as create.\n\n**Session bearers only.** An API key is minted for exactly one profile — it cannot mint, revoke or rotate a sibling key — so this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Analytics"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"201":{"description":"The new key, including its one-time plaintext secret","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiKeyCreated"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Only ACTIVE keys can be rotated","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Key not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["tracking:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/outcome-definitions/{id}":{"patch":{"summary":"Update one outcome-definition's event key","description":"The ONE column the settings page's inline editor changes: which milestone (`event_key`) this CRM pipeline-stage LABEL maps to. `outcome_definitions` are NOT `conversion_definitions` — see `GET /api/v1/goals/definitions`'s description for the distinction. A definition belonging to another profile answers 404, byte-identical to one that does not exist.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutcomeDefinitionUpdate"}}}},"responses":{"200":{"description":"The updated definition","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/OutcomeDefinitionRow"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the goals:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The definition does not exist, or belongs to another profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["goals:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profile/outcome-definitions":{"put":{"summary":"Replace one family of outcome definitions","description":"Delete-then-insert every `outcome_definitions` row of ONE family (`pipeline_stage` | `order_event`) for this profile, replacing it with the given list. This is the shape onboarding's finish/skip step and the settings page's mode-switch seeding both use to (re)write a whole pipeline-stage or order-event ladder at once — there is no per-row create; use this with the family's full desired list, including rows that are not changing.\n\nNOT wrapped in a database transaction: a failure between the delete and the insert can leave the family empty. The browser code this route replaces was not atomic either (two sequential calls with no rollback), so this is not a regression.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutcomeDefinitionsReplaceRequest"}}}},"responses":{"200":{"description":"The family's new rows","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/OutcomeDefinitionsReplaceResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the goals:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["goals:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profile/audit-events":{"post":{"summary":"Stamp one allowlisted UI-interaction audit event","description":"NOT a general audit-log proxy. `event_type` must be one of a closed allowlist — today the dark-messaging-spend banner's own two interaction stamps, which have no accompanying data write of their own (unlike `onboarding_completed`, which is folded into `PATCH /api/v1/profile`). The actor is always the authenticated session; it can never be named in the body.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileAuditEventCreate"}}}},"responses":{"200":{"description":"The event was recorded","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProfileAuditEventResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the attribution:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profile/dark-messaging-spend":{"get":{"summary":"Estimate revenue at risk from unattributed Instagram DM conversations","description":"Nudges toward connecting an Instagram Business account when Meta reports messaging conversations driven by this profile's ads but Atribu has no matching `conversations` rows. Returns `null` when there is nothing to flag: the profile already has an Instagram connection, there are fewer than 50 unobserved conversations in the last 30 days, or nothing to report at all — the same three cases the console's own banner rendered identically (nothing) before this route existed.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The spend estimate, or null when there is nothing to flag","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"allOf":[{"$ref":"#/components/schemas/DarkMessagingSpend"},{"type":["object","null"]}]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/workspaces/{workspaceId}/mcp-settings":{"get":{"summary":"Get the workspace's MCP data-plane settings","description":"PII default and write-back enablement for agent (MCP) tools acting on this workspace. Workspace grain, like `GET /api/v1/workspaces/{workspaceId}/audit` — an agency's own operational settings, not a dealer's marketing.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"uuid"},"required":true,"name":"workspaceId","in":"path"}],"responses":{"200":{"description":"The workspace's MCP settings","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/McpSettings"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the attribution:write scope, or not an active member of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Workspace not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"},"put":{"summary":"Update the workspace's MCP data-plane settings","description":"Owner/admin only — an analyst may read this panel but not change it, enforced beyond the route's own scope gate. A partial update: an omitted key is left as it was.","tags":["Analytics"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","example":"uuid"},"required":true,"name":"workspaceId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpSettingsUpdate"}}}},"responses":{"200":{"description":"The updated settings","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/McpSettings"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the attribution:write scope, not a workspace admin, or not an active member","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Workspace not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Malformed body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/mcp-writeback-audits":{"get":{"summary":"List the workspace's MCP write-back audit log","description":"The last 50 write-back tool operations (e.g. send_meta_conversions), newest first. Preview rows are informational; confirm rows reached the ad platform.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"uuid"},"required":true,"name":"workspaceId","in":"path"}],"responses":{"200":{"description":"The audit log","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/McpWritebackAudit"}},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the attribution:write scope, or not an active member of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/conversion-sync/actions":{"get":{"summary":"List recent Meta action-layer log entries","description":"The audit trail of every mediated Meta write on this profile (applies, previews, failures, rollbacks) — the same 50 most recent rows the internal dashboard's Conversion Sync console shows.","tags":["Conversion Sync"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"Recent action-log entries","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"actions":{"type":"array","items":{"$ref":"#/components/schemas/ConversionSyncActionLogEntry"}}},"required":["actions"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversion-sync/audit":{"get":{"summary":"List the signal-settings change-history log, cursor-paginated","description":"Every signal-configuration change on this profile (rule edits, privacy-mode changes, allowlist edits, DPA/BAA acceptance, …) — the internal dashboard's audit log tab, byte-identical.","tags":["Conversion Sync"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":200},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","description":"Opaque `created_at::id` cursor from a previous page's `nextCursor`."},"required":false,"description":"Opaque `created_at::id` cursor from a previous page's `nextCursor`.","name":"cursor","in":"query"}],"responses":{"200":{"description":"A page of audit-log entries","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"entries":{"type":"array","items":{"$ref":"#/components/schemas/ConversionSyncAuditLogEntry"}},"hasMore":{"type":"boolean"},"nextCursor":{"type":["string","null"]}},"required":["entries","hasMore","nextCursor"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed cursor","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversion-sync/definition":{"get":{"summary":"The conversion-definition builder for click-to-WhatsApp funnels","description":"Composes the three honest conversion moments an owner can pick (funnel milestone / pipeline-stage tag / downstream cash) from the profile's real conversion definitions, CRM pipeline stages and connected payment providers, plus which one is currently wired to Meta CAPI. Read-only: saving a choice goes through the signal-rules write path, not this route.","tags":["Conversion Sync"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The conversion-definition catalog","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConversionSyncDefinitionResponse"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversion-sync/legal":{"get":{"summary":"The profile's BAA/HIPAA/DPA compliance state and documents","description":"Compliance readiness for Platform-Safe Mode / Healthcare Mode: BAA and HIPAA-eligibility status, the legal owner of record, and every uploaded/signed document with a time-limited signed URL.","tags":["Conversion Sync"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The profile's legal/compliance state","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConversionSyncLegalState"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversion-sync/audiences":{"get":{"summary":"List managed Meta Custom Audiences","description":"Every non-deleted Custom Audience (value-based lookalikes, exclusions, retargeting) proposed or synced from this profile, plus its recent create/sync/delete action-log entries. Read-only: proposing, confirming or managing an audience stays on the console for now.","tags":["Conversion Sync"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"Managed audiences + their recent actions","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConversionSyncAudiencesResponse"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"example":{"data":{"audiences":[{"id":"c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f","kind":"value_based","status":"synced","name":"High-value lookalike — 60d","value_mode":"cash","seed_mode":"top_percentile","meta_audience_id":"6789012345","parent_audience_id":null,"lookalike_spec":{"ratio":0.03,"country":"CL"},"member_count":850,"seed_stats":{"median":"45000.00","total":"12500000.00"},"last_synced_at":"2026-08-27T06:00:00.000Z","last_users_replace_at":"2026-08-27T06:00:00.000Z","approved_at":"2026-08-20T10:00:00.000Z"}],"actions":[{"id":"d4e5f6a7-b8c9-4d0e-1e2f-3a4b5c6d7e8f","action_kind":"sync","status":"completed","source":"cron","request_payload":null,"error_code":null,"created_at":"2026-08-27T06:00:00.000Z"}]},"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversion-sync/audiences/seed-preview":{"get":{"summary":"Preview the exact customer list a value-based audience proposal would upload","description":"The anti-black-box view: the exact customers (names + attributed cash + last payment) a lookalike seed proposal would upload, plus the honest aggregates (median, total, refunds subtracted). Read-only — builds the seed in memory, uploads nothing.","tags":["Conversion Sync"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The seed preview","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConversionSyncAudienceSeedPreview"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversion-sync/feed":{"get":{"summary":"The CAPI/Google Ads export deliveries feed","description":"A filtered, cursor-paginated page of `conversion_exports` rows, enriched with the outcome event, the credited attribution touch (never re-derived from raw UTMs — see the field's own description), the matched rule, the destination config, per-attempt delivery log, and customer context. Also returns the windowed status summary and the distinct event-name facet for the filter dropdown.","tags":["Conversion Sync"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["meta_capi","google_ads","tiktok_events_api","all"]},"required":false,"name":"destination","in":"query"},{"schema":{"type":"string","enum":["pending","sent","failed","skipped","dead_letter","all"]},"required":false,"name":"status","in":"query"},{"schema":{"type":"string","enum":["pending","sent","failed","skipped","dead_letter","all"]},"required":false,"name":"exportStatus","in":"query"},{"schema":{"type":"string","enum":["eligible","skipped","sent","failed","all"]},"required":false,"name":"eligibility","in":"query"},{"schema":{"type":"string","enum":["delivered","retrying","held","failed","all"]},"required":false,"name":"uiStatus","in":"query"},{"schema":{"type":"string"},"required":false,"name":"skipReason","in":"query"},{"schema":{"type":"string","format":"uuid"},"required":false,"name":"ruleId","in":"query"},{"schema":{"type":"string","maxLength":120},"required":false,"name":"eventName","in":"query"},{"schema":{"type":"string","enum":["7d","30d","all"],"description":"Superseded by from/to when either is present. Defaults to 7d."},"required":false,"description":"Superseded by from/to when either is present. Defaults to 7d.","name":"window","in":"query"},{"schema":{"type":"string","format":"date-time"},"required":false,"name":"from","in":"query"},{"schema":{"type":"string","format":"date-time"},"required":false,"name":"to","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string"},"required":false,"name":"cursor","in":"query"}],"responses":{"200":{"description":"A page of the deliveries feed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConversionSyncFeedResponse"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid query (malformed cursor, from/to out of order, or range over 400 days)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversion-sync/setup-links":{"get":{"summary":"The agency's live client setup link, if any","description":"The one-link (Pixel + Dataset) onboarding link currently active for this profile — its lifecycle status, provisioning stage progress, and (once ready) the result summary. Read-only: generating a new link or requesting a permission fix stays on the console for now.","tags":["Conversion Sync"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The live setup link, or null when none exists","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConversionSyncSetupLinkResponse"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/conversion-sync/transparency":{"get":{"summary":"\"What we send to Meta\" transparency preview","description":"Runs the REAL privacy cleanse on one fixed, synthetic sample conversion, for both platform_safe and standard modes, using the profile's actual privacy configuration — never a hardcoded verdict. Returns the before/after breakdown per field so a compliance stakeholder can see exactly what leaves Atribu.","tags":["Conversion Sync"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The transparency preview for both privacy modes","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConversionSyncTransparencyResponse"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profile/branding":{"get":{"summary":"The profile's report/email branding","description":"Accent color, whether the Atribu footer is hidden, whether the profile falls back to the WORKSPACE's branding, and the logo if one is uploaded. `can_hide_footer` says whether the plan permits hiding the footer at all.","tags":["Profile Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The current branding","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProfileBrandingOverview"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]},"example":{"data":{"branding":{"accentColor":"#0EA5E9","hideAtribuFooter":false,"useWorkspaceBranding":false,"logoStoragePath":"profiles/8f14e45f-ceea-467e-adde-3f4694a483fb/logo-3f4a5b6c.png","logoUpdatedAt":"2026-08-30T11:02:00.000Z","logoContentType":"image/png"},"logo_url":"https://branding.atribu.app/profiles/8f14e45f-ceea-467e-adde-3f4694a483fb/logo-3f4a5b6c.png?v=1756551720","can_hide_footer":true,"plan_id":"agency"},"meta":{"profile_id":"8f14e45f-ceea-467e-adde-3f4694a483fb"}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential does not hold the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"put":{"summary":"Update the profile's branding (non-logo fields)","description":"`hideAtribuFooter: true` requires an Agency or Enterprise plan (or the admin override) and answers `402 plan_upgrade_required` otherwise. Logo upload is the sibling `/branding/logo` route.\n\nField names are camelCase here, matching the already-shipped workspace twin `PUT /api/v1/workspaces/{workspaceId}/branding` field for field.","tags":["Profile Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"accentColor":{"type":["string","null"],"example":"#112233"},"hideAtribuFooter":{"type":"boolean"},"useWorkspaceBranding":{"type":"boolean","description":"True makes this profile render the workspace's branding. Uploading a profile logo sets it to false automatically."}}}}}},"responses":{"200":{"description":"The updated branding","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"branding":{"$ref":"#/components/schemas/ProfileBranding"}},"required":["branding"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter or body field failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"402":{"description":"The changed field requires an Agency/Enterprise plan","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential does not hold the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profile/branding/logo":{"post":{"summary":"Upload the profile's logo","description":"Multipart upload (`logo` file part; PNG/JPEG/SVG up to 2MB). An SVG carrying script-like content is rejected. Uploading also sets `useWorkspaceBranding` to false — an explicit profile logo IS the override. The previous object is best-effort deleted from storage after the new one is recorded.","tags":["Profile Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"logo":{"type":"string"}},"required":["logo"]}}}},"responses":{"200":{"description":"The updated branding, with the new logo URL","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"branding":{"$ref":"#/components/schemas/ProfileBranding"},"logo_url":{"type":["string","null"]}},"required":["branding","logo_url"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter or body field failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential does not hold the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"415":{"description":"Unsupported file type","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"delete":{"summary":"Remove the profile's logo","description":"Clears the logo fields and best-effort deletes the stored object.","tags":["Profile Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The updated branding","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"branding":{"$ref":"#/components/schemas/ProfileBranding"}},"required":["branding"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential does not hold the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profile/pacing":{"get":{"summary":"The profile's monthly budget and outcome goal","description":"The two numbers behind the Top Performers spend-pacing card, plus the currency each is denominated in. `profile_currency` is the profile's own reporting currency, which is what the budget is compared against when `monthly_budget_currency` is null.","tags":["Profile Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The current pacing settings","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProfilePacing"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential does not hold the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"put":{"summary":"Set the profile's monthly budget and outcome goal","description":"Every field is optional; an omitted field is left alone and an explicit `null` clears it. A body that sets nothing answers 400 rather than silently succeeding.","tags":["Profile Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"monthly_budget_cents":{"anyOf":[{"type":"string","format":"decimal","pattern":"^-?\\d+(\\.\\d+)?$","example":"1234.56"},{"type":"number"},{"type":"null"}]},"monthly_outcome_goal":{"type":["integer","null"]},"monthly_budget_currency":{"type":["string","null"],"example":"CLP","description":"ISO 4217, from the supported set. Null falls back to the profile's own."}}}}}},"responses":{"200":{"description":"The pacing settings after the write","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProfilePacing"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter or body field failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential does not hold the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profile/bio-page":{"get":{"summary":"The profile's hosted bio-page handle and config","description":"The handle behind `atribu.app/bio/{slug}` and the page's rendering config.","tags":["Profile Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The current bio page","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProfileBioPage"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential does not hold the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"put":{"summary":"Set the bio-page handle and config","description":"`slug` is REQUIRED and explicitly nullable — null clears the handle and frees it for another profile, and an absent key would be ambiguous between that and 'leave it'. The handle is globally unique: one already taken answers 409. `config.logo_url` is rendered into a public page, so it must be http(s) and must not point at a loopback, private, or cloud-metadata host.","tags":["Profile Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"slug":{"type":["string","null"]},"config":{"$ref":"#/components/schemas/BioPageConfig"}},"required":["slug"]}}}},"responses":{"200":{"description":"The stored bio page","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProfileBioPage"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter or body field failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential does not hold the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"That handle is already taken","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profile/bio-page/slug-availability":{"get":{"summary":"Whether a bio-page handle is free","description":"An inline availability check for the settings form, so a caller does not have to PUT to find out. Answers `available: true` for a handle the CALLER already owns, so the current value never flashes 'taken'. Nothing about who holds a taken handle is returned.","tags":["Profile Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The handle to test. Normalised (trimmed + lowercased) before checking.","example":"acme-motors"},"required":true,"description":"The handle to test. Normalised (trimmed + lowercased) before checking.","name":"candidate","in":"query"}],"responses":{"200":{"description":"The verdict","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/BioSlugAvailability"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential does not hold the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profile/share-destination":{"get":{"summary":"The profile's default destination for organic share links","description":"Where `/r/{slug}` sends a visitor when a short link carries no destination of its own. Null when unset.","tags":["Profile Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The current default","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProfileShareDestination"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential does not hold the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"put":{"summary":"Set or clear the default share destination","description":"`null` clears it. A non-null value must be http(s) and must not point at a loopback, private, or cloud-metadata host — the value powers a server-side redirect. Setting a destination also backfills short links reserved before it existed whose destination is still NULL (`backfilled` counts them); clearing never overwrites a stored value.","tags":["Profile Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"destination_url":{"type":["string","null"],"maxLength":2000}},"required":["destination_url"]}}}},"responses":{"200":{"description":"The stored default, and how many short links were backfilled","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProfileShareDestinationWrite"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter or body field failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential does not hold the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profile/recompute-fx":{"post":{"summary":"Re-normalise the profile's conversions after a currency change","description":"Resets every conversion whose stored currency disagrees with the profile's current one back to `normalization_status = 'pending'` and enqueues an INTERACTIVE attribution recompute, so the FX step re-fills them without waiting out the cadence window. Call it after changing `currency` on the profile.","tags":["Profile Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"How many conversions were reset","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProfileRecomputeFx"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"The profile has no currency set","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential does not hold the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/customers/search":{"get":{"summary":"Search the profile's customers by name, email or phone","description":"The merge-candidate picker's read: a fuzzy match over the profile's customers, for choosing which customer an unlinked conversation or payment belongs to. A query shorter than 2 characters returns an empty list rather than every customer.","tags":["Profile Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","description":"The search term. Under 2 characters returns []."},"required":true,"description":"The search term. Under 2 characters returns [].","name":"q","in":"query"},{"schema":{"type":"string","format":"uuid","description":"A customer id to leave out — the one already selected."},"required":false,"description":"A customer id to leave out — the one already selected.","name":"exclude","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":50,"example":20},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"The matching customers","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"candidates":{"type":"array","items":{"$ref":"#/components/schemas/CustomerSearchCandidate"}}},"required":["candidates"]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential does not hold the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/customers/{id}/journey-summary":{"get":{"summary":"The stored AI summary of one customer's journey","description":"Reads the cached summary and never calls a model — `cached: false` with a null summary means none has been generated. `{id}` is the same visitor id `GET /api/v1/customers` publishes: a customer id, or an anonymous id for a visitor who was never identified.","tags":["Profile Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The stored summary, if any","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/CustomerJourneySummary"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential does not hold the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such customer in this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Generate (and store) an AI summary of one customer's journey","description":"Reads the first 100 events of the customer's timeline and asks a model for a 2–3 sentence summary, replacing any stored one. Draws ONE unit from the workspace's daily AI allowance; when that is spent the answer is `429` with `Retry-After` naming the next UTC midnight. A customer with no events returns a null summary and costs nothing.","tags":["Profile Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The generated summary","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/CustomerJourneySummary"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter or body field failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential does not hold the required scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such customer in this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["customers:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/workspaces/{workspaceId}/profiles/{profileId}/invitations":{"get":{"summary":"Pending invitations to one profile","description":"Invitations that are neither accepted, revoked nor expired.\n\n**Session bearers only.** Membership administration has no API-key scope: `manage:members` is not in any key's vocabulary, and this route is workspace-scoped, so a key answers `403 insufficient_scope`. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Requires `manage:members` on THIS profile.** A workspace owner/admin/analyst holds it by inheritance; anyone else needs it in their `profile_memberships.permissions`. Granting `manage:members` to someone else is narrower still — only a workspace owner or admin may.","tags":["Profile Members"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"The profile whose members are being administered. It must belong to `workspaceId`; a profile in another workspace answers 404 — the same answer one that does not exist gets."},"required":true,"description":"The profile whose members are being administered. It must belong to `workspaceId`; a profile in another workspace answers 404 — the same answer one that does not exist gets.","name":"profileId","in":"path"}],"responses":{"200":{"description":"The pending invitations","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"invitations":{"type":"array","items":{"$ref":"#/components/schemas/ProfileInvitation"}}},"required":["invitations"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]},"example":{"data":{"invitations":[{"id":"c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f","email":"camila@riquezadigital.cl","profile_role":"viewer","permissions":["view:dashboard","view:attribution","view:reports"],"invited_by":"9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d","expires_at":"2026-09-14T12:00:00.000Z","created_at":"2026-09-07T12:00:00.000Z"}]},"meta":{"workspace_id":"3c9b1e2a-6f4d-4a8b-9c1e-2a6f4d4a8b9c"}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or lacking `manage:members` on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The profile is not in this workspace, or does not exist","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"},"post":{"summary":"Invite someone to one profile","description":"Creates (or replaces) the pending invitation for this email on this profile and, when the mailer is configured and the 60-second per-email cooldown has elapsed, sends the invitation email. `email_sent` says whether it actually went — the row and its accept link exist either way.\n\n`permissions` is optional: omitted, the role's default bundle is granted. `manage:members` can only be granted by a workspace owner or admin.\n\n**Session bearers only.** Membership administration has no API-key scope: `manage:members` is not in any key's vocabulary, and this route is workspace-scoped, so a key answers `403 insufficient_scope`. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Requires `manage:members` on THIS profile.** A workspace owner/admin/analyst holds it by inheritance; anyone else needs it in their `profile_memberships.permissions`. Granting `manage:members` to someone else is narrower still — only a workspace owner or admin may.","tags":["Profile Members"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"The profile whose members are being administered. It must belong to `workspaceId`; a profile in another workspace answers 404 — the same answer one that does not exist gets."},"required":true,"description":"The profile whose members are being administered. It must belong to `workspaceId`; a profile in another workspace answers 404 — the same answer one that does not exist gets.","name":"profileId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","example":"someone@example.com"},"profile_role":{"type":"string","enum":["manager","viewer"]},"permissions":{"type":"array","items":{"type":"string"},"description":"`view:*` / `manage:*` keys. Unknown keys are dropped. Omit for the role's default bundle."}},"required":["email","profile_role"]}}}},"responses":{"201":{"description":"The created invitation","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"invitation":{"$ref":"#/components/schemas/ProfileInvitation"},"email_sent":{"type":"boolean"}},"required":["invitation","email_sent"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter or body field failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or lacking `manage:members` on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The profile is not in this workspace, or does not exist","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"That person already has access to this profile or workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/profiles/{profileId}/invitations/{invitationId}":{"delete":{"summary":"Revoke a pending profile invitation","description":"404 when the invitation does not exist or was already accepted — an accepted invitation is undone by removing the MEMBERSHIP, not the invitation.\n\n**Session bearers only.** Membership administration has no API-key scope: `manage:members` is not in any key's vocabulary, and this route is workspace-scoped, so a key answers `403 insufficient_scope`. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Requires `manage:members` on THIS profile.** A workspace owner/admin/analyst holds it by inheritance; anyone else needs it in their `profile_memberships.permissions`. Granting `manage:members` to someone else is narrower still — only a workspace owner or admin may.","tags":["Profile Members"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"The profile whose members are being administered. It must belong to `workspaceId`; a profile in another workspace answers 404 — the same answer one that does not exist gets."},"required":true,"description":"The profile whose members are being administered. It must belong to `workspaceId`; a profile in another workspace answers 404 — the same answer one that does not exist gets.","name":"profileId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"invitationId","in":"path"}],"responses":{"200":{"description":"Revoked","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or lacking `manage:members` on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The profile is not in this workspace, or does not exist","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/profiles/{profileId}/members/{membershipId}":{"patch":{"summary":"Change a profile member's role or permissions","description":"Changing the role WITHOUT naming permissions resets them to that role's default bundle; naming them sets them outright. A body that changes nothing returns the membership unchanged.\n\n**Session bearers only.** Membership administration has no API-key scope: `manage:members` is not in any key's vocabulary, and this route is workspace-scoped, so a key answers `403 insufficient_scope`. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Requires `manage:members` on THIS profile.** A workspace owner/admin/analyst holds it by inheritance; anyone else needs it in their `profile_memberships.permissions`. Granting `manage:members` to someone else is narrower still — only a workspace owner or admin may.","tags":["Profile Members"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"The profile whose members are being administered. It must belong to `workspaceId`; a profile in another workspace answers 404 — the same answer one that does not exist gets."},"required":true,"description":"The profile whose members are being administered. It must belong to `workspaceId`; a profile in another workspace answers 404 — the same answer one that does not exist gets.","name":"profileId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"membershipId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"profile_role":{"type":"string","enum":["manager","viewer"]},"permissions":{"type":"array","items":{"type":"string"}}}}}}},"responses":{"200":{"description":"The updated membership","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"membership":{"$ref":"#/components/schemas/ProfileMembership"}},"required":["membership"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter or body field failed validation","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or lacking `manage:members` on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The profile is not in this workspace, or does not exist","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"},"delete":{"summary":"Remove someone from a profile","description":"Sets the membership's status to `removed` rather than deleting the row, so the audit trail keeps resolving.\n\n**Session bearers only.** Membership administration has no API-key scope: `manage:members` is not in any key's vocabulary, and this route is workspace-scoped, so a key answers `403 insufficient_scope`. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Requires `manage:members` on THIS profile.** A workspace owner/admin/analyst holds it by inheritance; anyone else needs it in their `profile_memberships.permissions`. Granting `manage:members` to someone else is narrower still — only a workspace owner or admin may.","tags":["Profile Members"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"The profile whose members are being administered. It must belong to `workspaceId`; a profile in another workspace answers 404 — the same answer one that does not exist gets."},"required":true,"description":"The profile whose members are being administered. It must belong to `workspaceId`; a profile in another workspace answers 404 — the same answer one that does not exist gets.","name":"profileId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"membershipId","in":"path"}],"responses":{"200":{"description":"Removed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or lacking `manage:members` on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The profile is not in this workspace, or does not exist","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/branding":{"get":{"summary":"The workspace's report/email branding","description":"Accent color, whether the Atribu footer is hidden, the sender name/reply-to for scheduled report emails, and the logo (if one is uploaded). Readable by any active member.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"The current branding","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WorkspaceBrandingOverview"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]},"example":{"data":{"branding":{"accentColor":"#0EA5E9","hideAtribuFooter":false,"emailFromName":"Riqueza Digital","emailReplyTo":"hola@riquezadigital.cl"},"logo_url":null,"can_hide_footer":true,"plan_id":"agency"},"meta":{"workspace_id":"3c9b1e2a-6f4d-4a8b-9c1e-2a6f4d4a8b9c"}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"},"put":{"summary":"Update the workspace's branding (non-logo fields)","description":"Sets accent color, whether the Atribu footer is hidden, and the sender name/reply-to. `hideAtribuFooter: true` and a non-empty `emailFromName`/`emailReplyTo` both require an Agency or Enterprise plan (or the admin override) and answer `402 plan_upgrade_required` otherwise. Logo upload is the sibling `/branding/logo` route.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Operator-only write.** Beyond the session-bearer requirement above, the caller's `workspace_role` on this workspace must be `owner` or `admin` — `analyst` and `guest` both answer `403 forbidden` here, even though they can read this same resource.","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"accentColor":{"type":["string","null"],"example":"#112233"},"hideAtribuFooter":{"type":"boolean"},"emailFromName":{"type":["string","null"]},"emailReplyTo":{"type":["string","null"]}}}}}},"responses":{"200":{"description":"The updated branding","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"branding":{"$ref":"#/components/schemas/WorkspaceBranding"}},"required":["branding"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid field value (e.g. a malformed accentColor or emailReplyTo)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"402":{"description":"The changed field requires an Agency/Enterprise plan","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/branding/logo":{"post":{"summary":"Upload the workspace's logo","description":"Multipart upload (`logo` file part; PNG/JPEG/SVG up to 2MB). An SVG is rejected if it contains script-like content. The previous logo object is best-effort deleted from storage after the new one is recorded.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Operator-only write.** Beyond the session-bearer requirement above, the caller's `workspace_role` on this workspace must be `owner` or `admin` — `analyst` and `guest` both answer `403 forbidden` here, even though they can read this same resource.","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"logo":{"type":"string"}},"required":["logo"]}}}},"responses":{"200":{"description":"The updated branding, with the new logo URL","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"branding":{"$ref":"#/components/schemas/WorkspaceBranding"},"logo_url":{"type":["string","null"]}},"required":["branding","logo_url"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing the logo part","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"415":{"description":"Unsupported file type","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"},"delete":{"summary":"Remove the workspace's logo","description":"Clears the logo fields and best-effort deletes the stored object.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Operator-only write.** Beyond the session-bearer requirement above, the caller's `workspace_role` on this workspace must be `owner` or `admin` — `analyst` and `guest` both answer `403 forbidden` here, even though they can read this same resource.","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"The updated branding, with no logo","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"branding":{"$ref":"#/components/schemas/WorkspaceBranding"}},"required":["branding"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/invitations":{"get":{"summary":"List pending workspace invitations","description":"Unaccepted, unrevoked, unexpired workspace-level invitations (never a profile-level invite — those are a separate surface). Operator-only, unlike most reads on this surface: an invitation names an email address a guest has no reason to see.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Operator-only write.** Beyond the session-bearer requirement above, the caller's `workspace_role` on this workspace must be `owner` or `admin` — `analyst` and `guest` both answer `403 forbidden` here, even though they can read this same resource.","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"Pending invitations","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"invitations":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvitation"}}},"required":["invitations"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"},"post":{"summary":"Invite someone to the workspace","description":"Creates (or re-issues, resetting the token and 7-day expiry) a workspace-level invitation and, when the deployment has an email provider configured, sends it — `email_sent` says whether that actually happened. A 60-second per-recipient cooldown suppresses a resend without erroring; the invitation row is still (re)created either way, so the accept link always works. `409 invalid_state` when the address is already an active member.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Operator-only write.** Beyond the session-bearer requirement above, the caller's `workspace_role` on this workspace must be `owner` or `admin` — `analyst` and `guest` both answer `403 forbidden` here, even though they can read this same resource.","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"},"role":{"type":"string","enum":["admin","analyst"]}},"required":["email","role"]}}}},"responses":{"201":{"description":"The invitation","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"invitation":{"$ref":"#/components/schemas/WorkspaceInvitation"},"email_sent":{"type":"boolean"}},"required":["invitation","email_sent"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing/invalid email or role","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"The address is already an active member of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/invitations/{invitationId}":{"delete":{"summary":"Revoke a pending workspace invitation","description":"No-ops (still 200) if the invitation was never accepted; `404` if it does not exist, belongs to a different workspace, or was already accepted.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Operator-only write.** Beyond the session-bearer requirement above, the caller's `workspace_role` on this workspace must be `owner` or `admin` — `analyst` and `guest` both answer `403 forbidden` here, even though they can read this same resource.","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"invitationId","in":"path"}],"responses":{"200":{"description":"Revoked","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"success":{"type":"boolean","enum":[true]}},"required":["success"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Not found, in another workspace, or already accepted","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/sender-domain":{"get":{"summary":"The workspace's verified sending domain, if any","description":"`can_manage` tells the caller whether THEY may write to this resource (owner/admin); `entitled` tells them whether the WORKSPACE'S PLAN allows it at all (Agency/Enterprise) — reading stays available after a downgrade so an operator can see and remove what they configured. `platform_sender_address` is what mail falls back to with no domain configured.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"The sender-domain overview","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"domain":{"allOf":[{"$ref":"#/components/schemas/WorkspaceSenderDomain"},{"type":["object","null"]}]},"can_manage":{"type":"boolean"},"entitled":{"type":"boolean"},"platform_sender_address":{"type":"string"}},"required":["domain","can_manage","entitled","platform_sender_address"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"},"post":{"summary":"Register a sending domain for this workspace","description":"Registers `domain` with the email provider and stores the DNS records to publish. One domain per workspace and one workspace per domain (`409 invalid_state` on either collision). Requires an Agency/Enterprise plan (`402 plan_upgrade_required`).\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Operator-only write.** Beyond the session-bearer requirement above, the caller's `workspace_role` on this workspace must be `owner` or `admin` — `analyst` and `guest` both answer `403 forbidden` here, even though they can read this same resource.","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"domain":{"type":"string"}},"required":["domain"]}}}},"responses":{"201":{"description":"The registered domain, with its DNS records to publish","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WorkspaceSenderDomain"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid domain","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"402":{"description":"Requires an Agency/Enterprise plan","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"Already set up for this workspace, or claimed by another one","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"503":{"description":"Email service not configured on this deployment","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"},"delete":{"summary":"Remove the workspace's sending domain","description":"Deregisters it from the email provider (best-effort) and deletes the row. Always available, even on a downgraded plan — removal only moves sending back toward the platform default. Idempotent: already-gone is a 200, not a 404.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Operator-only write.** Beyond the session-bearer requirement above, the caller's `workspace_role` on this workspace must be `owner` or `admin` — `analyst` and `guest` both answer `403 forbidden` here, even though they can read this same resource.","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"Removed (or already gone)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/sender-domain/verify":{"post":{"summary":"Re-check DNS verification for the workspace's sending domain","description":"Asks the email provider to re-verify the domain's DNS records and persists the refreshed status. Idempotent and cheap to poll while waiting on DNS propagation. `404` if no domain is registered yet.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Operator-only write.** Beyond the session-bearer requirement above, the caller's `workspace_role` on this workspace must be `owner` or `admin` — `analyst` and `guest` both answer `403 forbidden` here, even though they can read this same resource.","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"The refreshed domain status","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WorkspaceSenderDomain"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No sending domain registered for this workspace yet","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"502":{"description":"The email provider could not be reached or returned no status","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"503":{"description":"Email service not configured on this deployment","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/snapshots":{"get":{"summary":"List shared Top Performers snapshot links","description":"The most recent 50 snapshot links this workspace has captured, newest first.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"The workspace's snapshot links","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"snapshots":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceSnapshotSummary"}}},"required":["snapshots"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"},"post":{"summary":"Capture a shareable Top Performers snapshot","description":"Freezes the workspace's current Top Performers leaderboard (plus the G1 brief and, optionally, an S-1 LLM narrative and the open recommendations queue) into a public, unauthenticated, expiring link at `public_url`. The underlying data is queried fresh at capture time — never trusted from the client.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Operator-only write.** Beyond the session-bearer requirement above, the caller's `workspace_role` on this workspace must be `owner` or `admin` — `analyst` and `guest` both answer `403 forbidden` here, even though they can read this same resource.","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"scoreWindow":{"type":"string","enum":["7d","14d","28d","lifetime"]},"topN":{"type":"integer","minimum":5,"maximum":50},"expirationHours":{"anyOf":[{"type":"number","enum":[24]},{"type":"number","enum":[168]},{"type":"number","enum":[336]},{"type":"number","enum":[720]}]},"title":{"type":["string","null"],"maxLength":140},"sections":{"type":"object","properties":{"brief":{"type":"boolean"},"stats":{"type":"boolean"},"leaderboard":{"type":"boolean"},"recs":{"type":"boolean"}},"description":"Defaults to { brief: true, stats: true, leaderboard: true, recs: false } — recs default OFF because they reveal the operator's own action plan."},"recipients":{"type":"array","items":{"type":"string","format":"email"},"maxItems":20}},"required":["scoreWindow","topN","expirationHours"]}}}},"responses":{"201":{"description":"The captured snapshot's public link","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WorkspaceSnapshotCreated"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Request body must be valid JSON","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"A field failed validation (e.g. topN out of 5–50, an unsupported expirationHours)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/snapshots/{snapshotId}":{"delete":{"summary":"Revoke a shared snapshot link early","description":"Any operator may revoke any workspace member's link, not only the one who created it — an offboarded teammate's outstanding links must still be revocable.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Operator-only write.** Beyond the session-bearer requirement above, the caller's `workspace_role` on this workspace must be `owner` or `admin` — `analyst` and `guest` both answer `403 forbidden` here, even though they can read this same resource.","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"snapshotId","in":"path"}],"responses":{"200":{"description":"Revoked","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Not found, in another workspace, or already revoked","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/connected-apps":{"get":{"summary":"List OAuth-app authorizations across the workspace's profiles","description":"Every ACTIVE `oauth_app_authorizations` row for any profile in this workspace — \"what has access to my data,\" the GDPR-facing subscription-management surface. `kind: \"connection\"` is a grant over one messaging connection (`provider` is set); `kind: \"profile\"` is an analytics delegation over the whole Profile (`provider` is null, never defaulted to a channel it is not).\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"Active authorizations","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"authorizations":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceConnectedApp"}}},"required":["authorizations"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/connected-apps/{id}":{"delete":{"summary":"Revoke an OAuth-app authorization","description":"Revokes the grant and cascades to every `atb_live_` key minted under it — `keys_revoked` counts them (an app may have several, one per re-consent). Idempotent: revoking an already-revoked grant answers `revoked: false` rather than an error.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Operator-only write.** Beyond the session-bearer requirement above, the caller's `workspace_role` on this workspace must be `owner` or `admin` — `analyst` and `guest` both answer `403 forbidden` here, even though they can read this same resource.","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"workspaceId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The revoke outcome","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"revoked":{"type":"boolean"},"api_key_revoked":{"type":"boolean"},"keys_revoked":{"type":"integer"}},"required":["revoked","api_key_revoked","keys_revoked"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Not found, or belongs to a profile outside this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/compliance-overview":{"get":{"summary":"Per-profile privacy/HIPAA compliance posture","description":"One row per profile: its attribution privacy mode, BAA status, HIPAA-eligibility status, e-signature status and how many exports are currently active — plus the workspace's own `is_healthcare_agency` flag.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"The compliance overview","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WorkspaceComplianceOverview"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"},"patch":{"summary":"Set whether this workspace is a healthcare agency","description":"Drives the HIPAA-specific gates elsewhere in the product (BAA requirement, PHI handling prompts). A one-field flag, not a full compliance edit — the per-profile BAA/signature workflow lives elsewhere.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Operator-only write.** Beyond the session-bearer requirement above, the caller's `workspace_role` on this workspace must be `owner` or `admin` — `analyst` and `guest` both answer `403 forbidden` here, even though they can read this same resource.","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"isHealthcareAgency":{"type":"boolean"}},"required":["isHealthcareAgency"]}}}},"responses":{"200":{"description":"The updated flag","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"is_healthcare_agency":{"type":"boolean"}},"required":["is_healthcare_agency"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"isHealthcareAgency must be a boolean","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/workspaces/{workspaceId}/reporting-currency":{"get":{"summary":"The currency this workspace's cross-profile totals are reported in","description":"ISO-4217 code. Every WORKSPACE-grain money figure — the Top Performers brief's `at_risk_spend_dollars` and `open_recs_value_dollars`, the pacing strip's totals, `projected_budget_at_risk` — is converted into this code before it is summed, because the profiles it sums may each report in a different one.\n\nIt is NOT a per-profile setting: `profiles.currency` still governs every profile-grain amount, and those are never converted.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"responses":{"200":{"description":"The workspace's reporting currency","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"reporting_currency":{"type":"string","example":"CLP"}},"required":["reporting_currency"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"},"patch":{"summary":"Set the currency this workspace's cross-profile totals are reported in","description":"Takes a 3-letter ISO-4217 code; lower case is accepted and upper-cased. Changing it re-denominates every workspace-grain total on the next read — it does NOT rewrite any stored amount, and no profile-grain figure moves.\n\nA row whose currency has no exchange rate against the new one is EXCLUDED from those totals rather than converted at an assumed rate of 1; the reads report how many in `fx_missing_rows`.\n\n**Session bearers only.** An API key is minted for exactly one profile — the credential IS the scope — so it has no workspace to act on and this route answers `403 insufficient_scope` for one. Present a Supabase session bearer (the credential Atribu's own console uses).\n\n**Operator-only write.** Beyond the session-bearer requirement above, the caller's `workspace_role` on this workspace must be `owner` or `admin` — `analyst` and `guest` both answer `403 forbidden` here, even though they can read this same resource.","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets."},"required":true,"description":"The workspace to read or change. A workspace you are not an ACTIVE member of answers 404 — the same answer a workspace that does not exist gets.","name":"workspaceId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"reportingCurrency":{"type":"string","example":"CLP"}},"required":["reportingCurrency"]}}}},"responses":{"200":{"description":"The updated currency","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"reporting_currency":{"type":"string","example":"CLP"}},"required":["reporting_currency"]},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"reportingCurrency must be a 3-letter ISO-4217 code","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Not a session bearer, or (on a write) not an owner/admin of this workspace","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or this caller is not an active member of it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/invitations/accept":{"post":{"summary":"Accept a workspace or profile invitation","description":"The caller must already be signed in with the SAME email the invitation was sent to (`403 forbidden` on a mismatch) — this route never creates an account; the brand-new-user path is the console's own signup flow. Idempotent membership creation: re-activates a removed/invited row rather than duplicating it, and never downgrades an existing higher-tier role. `409`/`410` on an already-accepted, revoked or expired invitation, or `410 workspace_archived` when the invitation's workspace has since been archived (terminal — not retryable).\n\n**Session bearers only** — there is no key to present before the caller has a workspace.","tags":["Workspace Admin"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string"},"invitationId":{"type":"string","format":"uuid"}},"required":["token","invitationId"]}}}},"responses":{"200":{"description":"The workspace (and, for a profile invite, the profile) the caller now belongs to","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"workspace_id":{"type":"string","format":"uuid"},"profile_id":{"type":["string","null"],"format":"uuid"}},"required":["workspace_id","profile_id"]},"meta":{"type":"object","properties":{"user_id":{"type":"string","example":"uuid"}},"required":["user_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing token or invitationId","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Signed in with a different email than the invitation was sent to","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"Invitation not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"Already accepted","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"410":{"description":"Revoked, expired, or the invitation's workspace was archived (`workspace_archived`)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"user"}},"/api/v1/profile/readiness":{"get":{"summary":"Get the profile's setup checklist and the one next thing to do","description":"An ordered checklist of what this profile still needs before ads, outcomes and attribution work end to end — and, for each unfinished step, the one next action.\n\n**Derived, never stored.** Every step is computed at read time from the state the rest of the product keeps: connections, tracker events, conversion definitions, attribution windows, export destinations and rules, the DPA, the plan, and whether Atribu's attribution pipeline is enabled for this profile at all. There is no checklist table and nothing here is a cached snapshot, so a connection that broke an hour ago shows as `degraded` on the next call.\n\n**`wizard_finished` is not the answer to this question.** It is the legacy `onboarding_completed` boolean — a person pressed Finish — and every wizard step is skippable, so it is reported alongside the steps rather than folded into them.\n\n**A partner-provisioned profile answers `attribution_enabled: blocked`, not an empty checklist.** Profiles created through a partner application deliberately do not hold the `atribu_attribution` entitlement, and Atribu's pipeline is a no-op for them — so every other step can be completed and still produce no attributed conversions. That is reported as the first step, with its reason.\n\nRead `summary.next_step` for the single thing to do now; read `steps` to render the whole flow. This response carries no account ids, no pixel ids, no amounts and no PII.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The profile's setup checklist","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProfileReadiness"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The profile does not exist, or this caller cannot reach it","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profile/attribution-settings":{"get":{"summary":"Get the profile's attribution windows","description":"The four window settings that decide how far back a touch still gets credit for a conversion: `click_window_days`, `view_window_hours`, `first_touch_window_days` and `cash_window_days`. Returns the table's own defaults (30 / 24 / 90 / null) when the profile has no `profile_attribution_settings` row yet — the same behavior the console's settings page has always had.\n\nThis is a NARROW slice of that table: session timeout/mode, geo/user-agent/channel enrichment toggles and the Meta/Google export switches live on the same row but have no v1 surface yet and are not returned here.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The profile's attribution windows","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AttributionSettings"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"patch":{"summary":"Update the profile's attribution windows","description":"A PARTIAL update over the four window columns — an omitted key is left untouched, matching `PATCH /api/v1/profile`'s convention. `cash_window_days: null` clears the override (cash conversions fall back to the normal windows); omitting it leaves the current value alone.\n\nA window change redefines which touches attach to which conversion RETROACTIVELY, so every successful save enqueues ONE full-profile attribution replay — reported as `replay_queued` (+ `replay_error` when it could not be queued), the same non-fatal, REPORTED tail `POST /api/v1/channel-rules` uses for the identical reason. An empty body (no recognized field) is a no-op: it returns the current settings unchanged with `replay_queued: false`.\n\nOut-of-range values answer `400 invalid_parameter` naming the field: `click_window_days` 1–365, `view_window_hours` 1–168, `first_touch_window_days` 1–365, `cash_window_days` null or 1–730.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttributionSettingsUpdate"}}}},"responses":{"200":{"description":"The updated (or unchanged) settings, plus the replay outcome","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AttributionSettingsUpdateResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A window field is out of range, or the body is malformed","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the goals:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["goals:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profiles/demo":{"post":{"summary":"Create and seed this workspace's demo profile","description":"One call gives a workspace something to read before any live account is connected: a profile flagged `is_demo`, sixty days of a fictional clinic's Meta traffic, and a Meta CAPI destination that RECORDS what it would have sent.\n\n**The engine runs for real.** The seed writes INPUTS — ad structure, daily spend, identities, marketing touches, and `outcome_events` through the same CRM-ingest primitive every real provider uses — then enqueues an ordinary recompute. `conversions` and `attribution_touchpoints` across all five attribution models come out of the pipeline, not out of the seed. That is why the counts below report what the recompute WILL produce: it runs asynchronously, so poll `GET /api/v1/overview`.\n\n**What the demo is excluded from.** Its own reads show its own money — that is the point. Every read that spans the profiles of a workspace leaves it out: the pacing aggregate, Top Performers cohorts and the `creative_feature_store` rebuild, the agency Reports hub, alert digest emails, the ML ad-fact and creative-lifecycle passes, and the `active_profiles` plan limit (a demo consumes no seat, and creating one is not gated by the limit).\n\n**Exports never leave.** See `destination.mode`.\n\n**Idempotent.** A workspace holds at most one demo profile. Calling this again re-seeds the existing one and answers 200; only the first call answers 201.\n\nSession bearers and user tokens only — an API key is minted for exactly one profile and belongs to no workspace, so it answers `403 insufficient_scope`.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to create the demo profile in. REQUIRED. You must hold an ACTIVE owner or admin membership on it — an analyst, a guest, or a workspace you do not belong to all answer 404, identical to a workspace that does not exist."},"required":true,"description":"The workspace to create the demo profile in. REQUIRED. You must hold an ACTIVE owner or admin membership on it — an analyst, a guest, or a workspace you do not belong to all answer 404, identical to a workspace that does not exist.","name":"workspace_id","in":"query"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDemoProfileRequest"}}}},"responses":{"200":{"description":"The workspace already had a demo profile — it was re-seeded","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DemoProfileCreated"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"201":{"description":"The demo profile was created and seeded","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DemoProfileCreated"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Malformed body, an unknown field, or a missing/invalid workspace_id","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key (single-profile by design), or it lacks analytics:read","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or you are not an active owner/admin member","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"},"delete":{"summary":"Delete this workspace's demo profile","description":"Removes the demo profile and everything under it — touches, outcome events, conversions, attribution, spend, the seeded ad structure, the recording destination and its ledger — in one call.\n\n**Delete means archive + purge, and audit rows are retained (#1170).** `conversion_exports` records what Atribu transmitted (or, for a demo, would have transmitted) on your behalf, and a database trigger refuses to let any user-facing path erase it. Those rows — and the handful their foreign keys pin in place (the exported outcome event, its customer, the destination and the rule) — stay attached to the archived profile; `retained_audit_rows` counts them. Everything else is gone.\n\nThe archived profile disappears from every read (`archived_at IS NULL` is the filter the whole product already applies) and **releases the workspace's demo slot** (`idx_profiles_one_demo_per_workspace` is partial on `archived_at IS NULL`), so `POST /api/v1/profiles/demo` succeeds immediately afterwards.\n\nThe `is_demo` predicate is inside the statement, so this can never touch a real profile.\n\nDeleting nothing answers 200 with `deleted: false`, not 404 — a workspace with no demo profile is the state you asked for, and a retry must not look like a failure.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The workspace to create the demo profile in. REQUIRED. You must hold an ACTIVE owner or admin membership on it — an analyst, a guest, or a workspace you do not belong to all answer 404, identical to a workspace that does not exist."},"required":true,"description":"The workspace to create the demo profile in. REQUIRED. You must hold an ACTIVE owner or admin membership on it — an analyst, a guest, or a workspace you do not belong to all answer 404, identical to a workspace that does not exist.","name":"workspace_id","in":"query"}],"responses":{"200":{"description":"The demo profile is gone (or there was none)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DemoProfileDeleted"},"meta":{"type":"object","properties":{"workspace_id":{"type":"string","example":"uuid"}},"required":["workspace_id"]}},"required":["data","meta"]}}}},"400":{"description":"Missing or invalid workspace_id","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"The credential is an API key, or it lacks analytics:read","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"The workspace does not exist, or you are not an active owner/admin member","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"`demo_purge_blocked` — a database trigger refused part of the purge because the rows are audit evidence. The profile is archived regardless; retrying will not change the outcome.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"insufficient_scope"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"workspace"}},"/api/v1/connections/sync":{"post":{"summary":"Start a sync","description":"Queue a manual sync for this profile — one provider, or every connected provider when `provider` is omitted.\n\n**Asynchronous.** `202` means the jobs are queued, not that data has landed; poll `GET /api/v1/connections/sync/status?provider=…` for progress, or read `GET /api/v1/connections` for `sync.last_synced_at`.\n\n**Not keyed by connection id, on purpose.** A profile can hold several connections for one provider (several Meta ad accounts, several GoHighLevel locations), and a sync covers all of them. A route that named one id would enqueue for its siblings anyway; this one says so.\n\n**A sync already in flight is a `409`,** not a second run: the queue holds one job per connection and a duplicate would be dropped silently.\n\nScope: `attribution:write` — a sync changes what the warehouse will say.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectionSyncRequest"}}}},"responses":{"202":{"description":"The sync jobs are queued.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConnectionSyncResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"`resource` was given with a provider other than `gohighlevel`","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_state"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"A sync is already in progress for this connection","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_state"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"422":{"description":"Unknown provider or resource, or a malformed body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_state"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/connections/sync/status":{"get":{"summary":"Read a provider's sync progress","description":"Where one provider's sync has got to on this profile. Safe to poll — the response is `Cache-Control: no-store`, and a progress bar that caches is a progress bar that stops.\n\n**No connection is a 200,** describing a `disconnected` provider with an empty `resources` list. A 404 would make \"not connected\" indistinguishable from \"wrong profile\".\n\nScope: `analytics:read`.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["whatsapp","instagram","email","google_calendar","google_calendar_read","microsoft_calendar_read","meta_ads","google_ads","google_search_console","gohighlevel","shopify","stripe","mercadopago","notion","manychat","fintoc"],"description":"Required. Which connector's progress to report.","example":"gohighlevel"},"required":true,"description":"Required. Which connector's progress to report.","name":"provider","in":"query"}],"responses":{"200":{"description":"The provider's sync state on this profile.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConnectionSyncStatus"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"`provider` missing or not a known connector","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_state"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/connections/{id}/disconnect":{"post":{"summary":"Disconnect an integration","description":"Delete the connection: stop the provider's push channel, trigger the GoHighLevel Marketplace uninstall where one applies, remove the row, emit `connection.revoked` and write an audit event.\n\n**This is not `DELETE /api/v1/connections/{id}`.** That one is a consumer app's OAuth grant SELF-revoke: it needs an API key, it cuts the keys that grant minted, and it leaves the tenant's connection in place. This one is the tenant giving up their own integration, and it removes it.\n\n**Requires a person.** A signed-in session or an MCP user token; an API key is a 403, because deleting a customer's Meta connection is not something a delegated read should be able to do.\n\n**A sync in flight is a 409.** Wait for it: the connector's rows are mid-write.\n\nScope: `attribution:write`.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","example":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The connection is gone.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DisconnectedConnection"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"403":{"description":"The credential is an API key; use the Atribu app, or the grant self-revoke","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_state"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such connection on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_state"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"409":{"description":"A sync is in progress","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_state"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profile/recompute":{"post":{"summary":"Recompute attribution","description":"Re-run sessions → paths → attribution for this profile. Queue this after anything that changes who gets credit: a customer merge, a channel rule, an attribution-window change.\n\n**Asynchronous.** `202` means queued; poll `GET /api/v1/profile/recompute-status`.\n\n**It skips the freshness cadence.** Background recomputes are throttled to roughly one per profile per window; a request made here is someone waiting on an answer, so it runs at the front. It still coalesces onto a queued job, so a loop cannot outrun the cap.\n\nScope: `attribution:write`.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileRecomputeRequest"}}}},"responses":{"202":{"description":"The recompute is queued.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProfileRecomputeResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"422":{"description":"Unknown attribution model, or a malformed body","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_state"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["attribution:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/profile/recompute-status":{"get":{"summary":"Is a recompute still running?","description":"One boolean, cheap enough to poll. Pair it with `POST /api/v1/profile/recompute` and with any write that queues a recompute of its own — a merge, a channel rule — to know when the numbers you read are the new ones.\n\nScope: `analytics:read`.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"Whether a recompute is outstanding.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ProfileRecomputeStatus"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/tracking/settings":{"get":{"summary":"Read the profile's tracker and enrichment settings","description":"How the tracker sessionises visits and what the enrichment pipeline resolves. The four attribution WINDOW columns live on the same database row but are a different resource — `GET /api/v1/profile/attribution-settings` — because they decide who gets CREDIT, not how an event is captured.\n\nA profile with no settings row yet gets the defaults rather than a 404.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The profile's tracking settings","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TrackingSettings"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"patch":{"summary":"Update the profile's tracker and enrichment settings","description":"A PARTIAL update — an omitted key is left untouched. `export_meta_enabled` / `export_google_enabled` are not accepted: they are owned by Conversion Sync.\n\nEvery successful save enqueues ONE full-profile replay, because these settings change how past events would have been sessionised and enriched. The outcome is REPORTED (`replay_queued`, `replay_error`) rather than thrown: the settings were saved either way, and a 500 would tell you the opposite of what happened. An empty body is a no-op — current settings, `replay_queued: false`.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrackingSettingsUpdateRequest"}}}},"responses":{"200":{"description":"The updated (or unchanged) settings, plus the replay outcome","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TrackingSettingsWriteResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter is malformed or out of range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the tracking:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["tracking:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/tracking/origin":{"get":{"summary":"Where this profile's tracker collects","description":"The profile's verified first-party tracking domain if it has one, else Atribu's own origin. This is the host a marketer pastes into an ad platform's allow-list, and the host the snippet from `GET /api/v1/tracking/snippet` points at for the same profile.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The resolved collection origin","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TrackingOrigin"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/tracking/events":{"get":{"summary":"The raw enriched-event feed","description":"Individual tracker events, newest first, for debugging an installation: what arrived, with which UTMs and click ids, and whether it carried an identity.\n\nREQUIRES `visitors:read`, not `analytics:read`. `payload` is the event's own body verbatim, and a `lead_submitted` payload carries the email/phone the tracker captured — so this is the PII tier, the same one `GET /api/v1/visitors` sits on.\n\nReads the Ephemeral Buffer, whose retention is 30 days. A longer `days` is CLAMPED, and the applied window comes back in `window.days` so a caller never attributes 30 days of numbers to the 90 it asked for. Page with `before` = the previous response's `pagination.next_cursor`.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","maxLength":64},"required":false,"name":"event_name","in":"query"},{"schema":{"type":"string","maxLength":256},"required":false,"name":"anonymous_id","in":"query"},{"schema":{"type":"string","format":"uuid"},"required":false,"name":"customer_profile_id","in":"query"},{"schema":{"type":"string","maxLength":64},"required":false,"name":"source_platform","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":365,"description":"Look-back window. CLAMPED to the Ephemeral Buffer's retention (30 days) — the applied value is echoed back in `window.days`, never silently substituted."},"required":false,"description":"Look-back window. CLAMPED to the Ephemeral Buffer's retention (30 days) — the applied value is echoed back in `window.days`, never silently substituted.","name":"days","in":"query"},{"schema":{"type":"string","description":"Keyset cursor — pass the previous page's `pagination.next_cursor`."},"required":false,"description":"Keyset cursor — pass the previous page's `pagination.next_cursor`.","name":"before","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"A page of enriched events","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TrackingEventsResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter is malformed or out of range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the visitors:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["visitors:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/tracking/replay-status":{"get":{"summary":"The profile's most recent attribution replay","description":"The latest `full_replay` run and its steps, so a client can show progress while attribution is being recalculated. `data` is `null` when the profile has never been replayed — a 200 with no run, not a 404: 'nothing is running' is an answer.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The latest replay, or null","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"allOf":[{"$ref":"#/components/schemas/TrackingReplayStatus"},{"type":["object","null"]}]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/tracking/verification":{"get":{"summary":"The latest install-verification attempt","description":"Poll this after `POST /api/v1/tracking/verification` to see whether the tracker on the customer's site has echoed the nonce back. `data` is `null` when no attempt was ever started.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The latest attempt, or null","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"allOf":[{"$ref":"#/components/schemas/TrackingVerificationAttempt"},{"type":["object","null"]}]},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Start an install verification","description":"Mints a nonce valid for 15 minutes. Load any tracked page with `?atb_verify=<nonce>` appended; the installed tracker posts it back and the attempt flips to `verified`, recording the URL and User-Agent it was seen on. This is the only proof that the snippet is actually live on the page the marketer thinks it is.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"201":{"description":"A fresh pending attempt","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TrackingVerificationAttempt"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the tracking:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["tracking:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/tracking/keys/{id}":{"delete":{"summary":"Revoke a tracking key","description":"Marks the key revoked. Events sent with it after this stop being accepted, so a site still carrying it goes dark.\n\nREFUSES (400) on the profile's LAST active key: the tracker authenticates with it, so revoking the only one would silently stop every event the site sends. Issue a replacement first with `POST /api/v1/tracking/keys` and `force_new: true`, deploy it, then revoke the old one. A key belonging to another profile answers 404, never 403 — a caller must not be able to probe which key ids exist. Revoking an already-revoked key is idempotent.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The key is revoked","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/RevokedTrackingKey"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"This is the profile's last active tracking key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the tracking:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such key on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["tracking:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/tracking/ops":{"get":{"summary":"Warehouse diagnostics for the profile","description":"What the ingestion and enrichment pipeline REJECTED or repaired over the window: malformed events, enrichment-stage failures, identity reassignments, and the analytics runs (plus their steps) that processed them. This is the read that answers 'my numbers look wrong — did something fail?'.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":365},"required":false,"name":"days","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"The diagnostics document","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TrackingOps"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter is malformed or out of range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/tracking/quality":{"get":{"summary":"Is this profile's tracking healthy?","description":"Rates and counts describing the SETUP, not the people: how many events arrived, how many carried a click id, how often `identify()` fired, and whether the Meta bridge and the CAPI export are actually working.\n\nTWO NUMBERS THAT LOOK LIKE ONE, deliberately kept apart. `summary.conversion_events` counts the TRACKER's conversion events; `business_conversions` counts the profile's conversions over the same window. A profile whose sales close in a CRM will show a large gap, and that gap is the honest answer rather than a fault.\n\n`diagnostics.meta_exports.sent` is a count over a bounded SAMPLE — read it with `sampled_conversions` and `complete`. Reads the Ephemeral Buffer, so `days` is clamped to its 30-day retention and the applied window is echoed in `window.days`.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":365},"required":false,"name":"days","in":"query"}],"responses":{"200":{"description":"The tracking-health document","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TrackingQuality"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter is malformed or out of range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/tracking/custom-domains":{"get":{"summary":"List the profile's first-party tracking domains","description":"A custom domain makes tracking FIRST-PARTY. Collection through `atribu.app` is a third-party request, which ad blockers and browser privacy lists drop outright — those visitors are not degraded, they are absent. `trk.yourstore.com` gets through.\n\n`cname_target` is what every domain here must point at.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"responses":{"200":{"description":"The profile's domains and the CNAME target","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TrackingDomainList"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the analytics:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"},"post":{"summary":"Register a first-party tracking domain","description":"Records the domain as `pending` and returns the CNAME to create. Nothing collects on it until `POST /api/v1/tracking/custom-domains/{id}/verify` confirms the DNS.\n\nIdempotent on `(profile, domain)`: re-registering the same host returns the same row. Answers 503 when the deployment has no collect host configured — a domain with no CNAME target could never verify, so it is refused rather than stored.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTrackingDomainRequest"}}}},"responses":{"201":{"description":"The domain, and the DNS steps to complete","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/CreateTrackingDomainResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter is malformed or out of range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the tracking:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"503":{"description":"No collect host is configured on this deployment","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["tracking:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"natural_key","x-atribu-scope-grain":"profile"}},"/api/v1/tracking/custom-domains/{id}/verify":{"post":{"summary":"Re-check a domain's DNS and store the verdict","description":"Resolves the domain and records whether it points at `expected_cname`. Passes on a matching CNAME **or** an IP overlap — a Cloudflare-proxied domain has its CNAME flattened to A/AAAA records, so a correct setup often has no CNAME to read.\n\n`verified: false` is a 200. It is a successful answer to \"does this resolve yet?\", which is what a client polling while the customer edits DNS needs.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The verdict, now stored on the domain","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/VerifyTrackingDomainResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter is malformed or out of range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the tracking:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such domain on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["tracking:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/tracking/custom-domains/{id}":{"delete":{"summary":"Remove a first-party tracking domain","description":"Snippets generated afterwards fall back to the next verified domain, or to Atribu's own origin. A site still carrying the removed host in an already-deployed snippet keeps sending to a hostname nothing answers, so regenerate the snippet before removing.","tags":["Tracking"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The domain is removed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DeletedTrackingDomain"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter is malformed or out of range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the tracking:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such domain on this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["tracking:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/exports/review":{"get":{"summary":"The delivery ledger, with the conversion each row carries","description":"The operational view of `conversion_exports`: every attempted delivery over the window, joined to the outcome event it was for.\n\nNOT the same as `GET /api/v1/exports/ledger`, which is the PII-free external view of the same table. This one hydrates the linked conversion (its type, time and amount) because the question it answers is \"which SALE failed to reach Meta\", not \"how many deliveries were there\".","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["meta_capi","google_ads"]},"required":false,"name":"destination","in":"query"},{"schema":{"type":"string","example":"2026-09-01","description":"YYYY-MM-DD, on `conversion_exports.created_at`."},"required":false,"description":"YYYY-MM-DD, on `conversion_exports.created_at`.","name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-09-07"},"required":false,"name":"date_to","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":1000},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"The ledger page and its summary","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ExportReview"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter is malformed or out of range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/exports/review/warnings":{"get":{"summary":"Is conversion export healthy over this window?","description":"The same window as `GET /api/v1/exports/review`, reduced to a verdict: pendings that have gone stale, an elevated failure rate, deliveries with no `external_event_id` (which cannot dedup against the Pixel at Meta), and abnormal retry volume. An empty `warnings` array is the healthy answer.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string","enum":["meta_capi","google_ads"]},"required":false,"name":"destination","in":"query"},{"schema":{"type":"string","example":"2026-09-01","description":"YYYY-MM-DD, on `conversion_exports.created_at`."},"required":false,"description":"YYYY-MM-DD, on `conversion_exports.created_at`.","name":"date_from","in":"query"},{"schema":{"type":"string","example":"2026-09-07"},"required":false,"name":"date_to","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":1000},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"The warnings, empty when healthy","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ExportReviewWarnings"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter is malformed or out of range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:read scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/exports/review/retry":{"post":{"summary":"Requeue failed and dead-letter exports","description":"Resets failed / dead-letter rows to `pending` and enqueues ONE dispatch job.\n\nTHIS ROUTE SENDS NOTHING. It writes `conversion_exports` and puts a message on the export queue; the pipeline-worker is the only code that opens a socket to Meta or Google — which is what keeps one implementation of booking-cluster dedup, the privacy guard, the legal gate and the retry ceiling.\n\nAn absent body retries every failed export on the profile; `destination` narrows it.","tags":["Conversions"],"security":[{"bearerAuth":[]},{"userToken":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExportRetryRequest"}}}},"responses":{"200":{"description":"How many rows were reset, and whether a dispatch was queued","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ExportRetryResult"},"meta":{"type":"object","properties":{"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"A parameter is malformed or out of range","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"403":{"description":"Missing the exports:write scope","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["exports:write"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/organic/posts":{"get":{"summary":"List organic posts with attribution","description":"One row per Instagram or Facebook post published in the window, with its engagement primitives and the conversions and cash revenue Atribu credits to it.\n\n`attributed_revenue` is CASH credit only — pipeline value is never folded in — and is computed under the profile's configured attribution model.\n\nSorting is server-side (`sort`), and pagination is cursor-based: read `pagination.cursor` and pass it back as `?cursor=`. The token is opaque; its contents are not part of the contract.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string","enum":["all","instagram","facebook"],"description":"Defaults to 'all'."},"required":false,"description":"Defaults to 'all'.","name":"platform","in":"query"},{"schema":{"type":"string","enum":["all","reel","video","image","carousel","story"],"description":"Defaults to 'all'."},"required":false,"description":"Defaults to 'all'.","name":"format","in":"query"},{"schema":{"type":"string","enum":["attributed_revenue","engagement","reach","recent","ai_score","fatigue"],"description":"Defaults to 'attributed_revenue'."},"required":false,"description":"Defaults to 'attributed_revenue'.","name":"sort","in":"query"},{"schema":{"type":"string","description":"Case-insensitive substring match on the caption. Max 200 characters."},"required":false,"description":"Case-insensitive substring match on the caption. Max 200 characters.","name":"search","in":"query"},{"schema":{"type":"string","description":"1-100. Defaults to 30."},"required":false,"description":"1-100. Defaults to 30.","name":"limit","in":"query"},{"schema":{"type":"string","description":"A `pagination.cursor` from a previous page."},"required":false,"description":"A `pagination.cursor` from a previous page.","name":"cursor","in":"query"}],"responses":{"200":{"description":"A page of organic posts","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/OrganicPost"}},"pagination":{"type":"object","properties":{"has_next":{"type":"boolean"},"cursor":{"type":"string","description":"Opaque token for the next page. Pass it back as `?cursor=`; do not construct one."}},"required":["has_next"]},"meta":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-03-01"},"date_to":{"type":"string","example":"2026-03-25"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","pagination","meta"]}}}},"400":{"description":"Invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/organic/posts/{id}":{"get":{"summary":"Get one organic post's attribution detail","description":"Credit for a single post: headline KPIs, the same post's credit under every attribution model, a per-day credited-revenue trend, and the customers it holds credit for.\n\n`{id}` is the platform's own post id — the `post_id` on each row of `GET /api/v1/organic/posts`. A post id belonging to another profile answers 404, never 403.\n\nThe `customers` array is customer identity and requires `customers:read` ON TOP of `analytics:read`; without it the field is null and the rest of the response is unchanged.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"date_from","in":"query"},{"schema":{"type":"string"},"required":true,"name":"date_to","in":"query"},{"schema":{"type":"string","description":"Defaults to 'last_touch'."},"required":false,"description":"Defaults to 'last_touch'.","name":"model","in":"query"}],"responses":{"200":{"description":"Post attribution detail","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/OrganicPostDetail"},"meta":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-03-01"},"date_to":{"type":"string","example":"2026-03-25"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such post for this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/organic/posts/{id}/intelligence":{"get":{"summary":"Get one organic post's AI analysis and twin ads","description":"The stored AI read on a post — score, fatigue, hook/CTA/emotional/audience notes, strengths, weaknesses, suggestions — plus the paid ads whose creative copy embeds nearest to its caption.\n\nNO DATE RANGE, deliberately. Everything here is a property of the CREATIVE: the analysis is one row written by a background pass, and the twin ads carry lifetime spend and lifetime credited revenue. Nothing moves when the caller changes the window, so accepting `date_from`/`date_to` and ignoring them would be worse than not accepting them. For windowed credit, use `GET /api/v1/organic/posts/{id}`.\n\nREAD-ONLY: this route serves what the metered background pass already wrote and never runs a model.\n\n`{id}` is the platform's own post id — the `post_id` on each row of `GET /api/v1/organic/posts`. A post id belonging to another profile answers 404, never 403. A post the pass has not reached answers 200 with `analysis: null`.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"How many twin ads to return. 1-20. Defaults to 5."},"required":false,"description":"How many twin ads to return. 1-20. Defaults to 5.","name":"limit","in":"query"}],"responses":{"200":{"description":"Post analysis and twin ads","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/OrganicPostIntelligence"},"meta":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-03-01"},"date_to":{"type":"string","example":"2026-03-25"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such post for this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}},"/api/v1/organic/stories/{id}":{"get":{"summary":"Get one Instagram story's attribution detail","description":"Credit for a single story: its insights row, headline KPIs, the customers it holds credit for, and the story replies Atribu matched to a touch.\n\n`{id}` is the story's `ig_media_id`. A story id belonging to another profile answers 404, never 403.\n\nNO DATE RANGE, deliberately: a story is a 24-hour object whose insights row is written once, and the read behind this route takes no window — its numbers are whatever the story's touches ever earned. Accepting `date_from`/`date_to` and ignoring them would be worse than not accepting them.\n\n`customers` and `replies` are customer identity and require `customers:read` ON TOP of `analytics:read`; without it both are null and the rest of the response is unchanged.","tags":["Analytics"],"security":[{"bearerAuth":[]},{"userToken":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"Defaults to 'last_touch'."},"required":false,"description":"Defaults to 'last_touch'.","name":"model","in":"query"}],"responses":{"200":{"description":"Story attribution detail","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/OrganicStoryDetail"},"meta":{"type":"object","properties":{"date_from":{"type":"string","example":"2026-03-01"},"date_to":{"type":"string","example":"2026-03-25"},"profile_id":{"type":"string","example":"uuid"}},"required":["profile_id"]}},"required":["data","meta"]}}}},"400":{"description":"Invalid parameter","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}},"404":{"description":"No such story for this profile","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"invalid_parameter"},"message":{"type":"string"},"status":{"type":"number"},"request_id":{"type":"string"}},"required":["code","message","status","request_id"]}},"required":["error"]}}}}},"x-atribu-scopes":["analytics:read"],"x-atribu-auth":["api_key","session","user_token"],"x-atribu-idempotency":"none","x-atribu-scope-grain":"profile"}}},"webhooks":{}}