API operations
All 21 operations on the Convultra API and MCP server, grouped by the question they answer, with every trap that would make a figure misleading.
On this page · 7 sections
The Convultra API and MCP server are in Beta. This is every operation, grouped the way a question arrives. Every path is relative to https://data.convultra.com/v1, and each operation has an MCP tool of the same name. {project} accepts a project UUID, a proj_... tracking key, or a domain such as acme.example, whichever you have.
You can usually omit {project}. If the credential can see exactly one project, the API resolves it. If it can see several, an omitted project is a 400 that names the candidates, so a script or an assistant can choose rather than guess.
What have I got, and is it working?
| Operation | Path | Scope | What it is for |
|---|---|---|---|
get_me | GET /me | none | What this credential is: name, scopes, reachable projects, rate limit. Call it first when you do not know which projects are available, and to confirm a new key works. |
get_metric_definitions | GET /meta/metrics | none | Convultra’s definitions of visitor, session, conversion, revenue, conversion rate, blended ROAS, CPA, AI referral and filtered-out traffic, plus the conversion event types, range presets and scope summaries. Read it before comparing any Convultra figure with another tool’s. |
list_projects | GET /projects | projects:read | Every project this credential can read, with tracking health, reporting currency and timezone. Pass include_archived=true for paused and archived ones. Use it to turn a domain into a project id. |
get_project | GET /projects/{project} | projects:read | One project in detail: attribution model and lookback, the four deduplication windows, currency, timezone, and which ad platforms are connected (status and timing only, never credentials). Reach for it to explain why figures are attributed as they are. |
get_tracking_health | GET /projects/{project}/tracking-health | projects:read | Whether the script is installed and receiving events, when the last event arrived, which custom tracking domains are live, whether monitoring mode is on, and the deduplication windows in force. |
Call get_tracking_health before concluding anything from a drop. A tracking outage and a bad week look identical in the numbers, and the outage is more common. monitoring_mode: true is its own trap: tracking is healthy and recording, but nothing is being sent to the ad platforms.
How did they do?
| Operation | Path | Scope | What it is for |
|---|---|---|---|
get_overview | GET /projects/{project}/overview | analytics:read | The metric strip as one call: revenue, conversions, conversion rate, visitors, sessions, pageviews, bounce rate, average order value, recovered conversions and revenue, AI-referral figures, filtered-out counts, and, with spend:read, spend, blended ROAS and CPA. compare=previous_period or compare=previous_year adds a previous block and a change block of percentage deltas. |
get_timeseries | GET /projects/{project}/timeseries | analytics:read | The same metrics as one row per hour, day, week or month. Every bucket in the range is present, zero-filled, so a gap in the array is a bug rather than a quiet day. Choose columns with metrics=revenue,conversions. |
Reach for get_overview when the question is “how did we do”; get_timeseries when it is “what changed and when”, or when drawing a chart.
get_timeseries traps, all things a chart would hide:
visitorsis unique within each bucket, so summing buckets double-counts people who came back. Ask for a widergranularityinstead; week and month buckets re-count uniques properly rather than adding days up.conv_rateis per bucket, conversions divided by visitors of that same bucket. Do not sum or average it across buckets.spendis unavailable at hourly granularity, because ad platforms report cost per day and spreading a daily figure over 24 hours would invent detail the platform never gave.granularity=hourrequires a range of 7 days or fewer.- Week and month buckets that extend past the requested range carry
partial: true. A half week that looks like a whole one is the commonest way a chart lies.
Where did the traffic come from?
| Operation | Path | Scope | What it is for |
|---|---|---|---|
get_breakdown | GET /projects/{project}/breakdown | analytics:read | Rank one dimension by traffic and revenue. One dimension= per call, 20 to choose from. |
get_ai_referrals | GET /projects/{project}/ai-referrals | analytics:read | Humans who arrived by clicking a link inside ChatGPT, Claude, Perplexity, Gemini, Copilot and similar: sessions, conversions and revenue per assistant, a daily timeline, and the pages they land on. |
get_filtered_out | GET /projects/{project}/filtered-out | analytics:read | The bot and AI-crawler activity Convultra detected and excluded from every reported figure, named per bot. Use it to answer “is a crawler inflating our numbers”: it is not, and this is the evidence. |
get_breakdown dimensions:
| Group | Dimensions |
|---|---|
| Acquisition | source, channel, referrer, paid_platform, ai_platform |
| Campaign | utm_source, utm_medium, utm_campaign, utm_term, utm_content |
| Content | page, entry_page, exit_page |
| Geography | country, region, city |
| Technology | device, browser, os |
| Behavior | event_type |
Every dimension returns the same row shape, { key, label, visitors, sessions, pageviews, bounce_rate, conversions, revenue, conv_rate }, with rates as 0 to 1 ratios, so you can switch dimension without switching parser. Narrow with search= (a case-insensitive substring on the dimension value), trim columns with metrics=, and page with limit= (default 25, maximum 500) and cursor=.
Four things to know before quoting a breakdown:
visitorsis alwaysnullhere. The underlying queries count sessions, not unique people, soconv_rateon these rows is conversions divided by sessions, a different denominator from the overview’s. Do not compare the two rates directly. The response says so inmeta.visitors_unavailable_reason.- Some dimensions cannot report conversions or revenue and return
nullfor them.nullmeans “this dimension cannot tell you”, not zero. - Some dimensions are capped at 10 or 20 rows with no way to page further. Then
meta.truncated_by_pipeistrue,has_moreisfalse, and the tail of the distribution is genuinely unavailable. Present the rows as the top ten, not the whole picture. On those dimensionssearchfilters the truncated top N, not the full set, and the response says so inmeta.search_scope. - Geography rows below five sessions are suppressed and counted in
meta.suppressed_rows. See Privacy and redaction.
source, channel and utm_campaign additionally carry spend, roas and cpa when the credential holds spend:read.
AI referrals and AI crawlers are different populations and must never be added. An AI referral is a human session: it counts in overview visitors, conversions and revenue already, so adding get_ai_referrals figures to a total double-counts. An AI crawler (GPTBot, ClaudeBot, PerplexityBot, CCBot) is a bot: it is filtered out at ingest and appears in no traffic, conversion or revenue figure anywhere in this API. Within get_filtered_out, do not add total_bot_events and total_ai_crawler_events either. Most AI crawler user agents contain “bot”, so they appear in both lists, and the AI figure is a subset view, not a separate population.
What converted?
| Operation | Path | Scope | What it is for |
|---|---|---|---|
list_events | GET /projects/{project}/events | analytics:read | Every event type this project recorded in the range, with how often it fired, how many sessions fired it, attached revenue, and is_conversion. Call this before guessing an event name. Names come from the customer’s own tracking code. |
get_event_timeseries | GET /projects/{project}/events/{event_type}/timeseries | analytics:read | One event type, day by day: count, unique_visitors, value. The follow-up to list_events when the question is “when did add_to_cart fall off”. |
get_funnel | GET /projects/{project}/funnel | analytics:read | How many sessions completed an ordered sequence of 2 to 6 event types, and where they dropped out. |
list_conversions | GET /projects/{project}/conversions | conversions:read | One row per conversion: value, currency, event type, order or lead id, first-touch source, geography, device, the page it happened on, the referring domain, ad click ids, recovery status and delivery status. |
get_conversion | GET /projects/{project}/conversions/{event_id} | conversions:read | One conversion in full, plus the visitor’s page-by-page journey through the session that converted, the time from first page to conversion, and every delivery attempt with its failure reason in plain English. |
get_attribution | GET /projects/{project}/attribution | conversions:read | How conversions and revenue are credited across ad platforms under last_click (default), first_click or linear, plus touchpoint depth and assisted conversions. |
get_recovery | GET /projects/{project}/recovery | conversions:read | How many conversions the ad platforms’ own pixels would have missed, and why: by reason, by platform and by day. |
Macro versus micro. Seven event types are conversions: purchase, lead, signup, subscription, download, contact, schedule. They feed the overview totals and are forwarded to the ad platforms. Everything else, including add_to_cart, view_content, begin_checkout, pageview and click, is a micro event and is deliberately excluded from conversion counts. is_conversion on every event row carries this, so you never infer it from a name.
get_funnel. List steps in the order they happen, comma-separated or repeated (steps=pageview,add_to_cart,purchase). A session counts for a step only if it fired every earlier step before it, within window_seconds (default 2592000, which is 30 days; maximum 90 days) of the first step. Steps must be distinct. A surprisingly empty funnel almost always means the steps are in the wrong order or an event name is wrong, so call list_events first. drop_off_rate and conversion_from_previous are null on step 1: there is no earlier step to be lost from, and a zero there would read as “nobody was lost”, which is a different claim.
list_conversions is the incremental-pull endpoint. Filter with event_type, source, min_value, delivery_status (delivered, pending, failed, not_forwarded), recovered (true or false) and since= (an ISO timestamp; only conversions strictly after it). Page with limit (default 50, maximum 200) and cursor. Its traps:
- Rows are newest first. Keep calling while
page.has_moreis true; do not stop on a short page. Filters are applied after the newest 2000 rows in the range have been fetched, so a filtered page can come back short while more matches still exist. - Paging beyond 2000 conversions in one range is refused with
400. Narrow the range, or pull incrementally withsince=. landing_pageandtime_to_convert_secondsare alwaysnullon this endpoint.get_conversioncomputes the latter from the session journey.delivery.status: "not_forwarded"means no forwarding attempt exists at all. That covers organic conversions, which are never sent, and micro events. It is not a failure.- Conversions the customer has removed are excluded.
get_conversion scans the newest conversions within lookback_days (default 90, maximum 400) because the underlying query cannot be keyed by event id. Anything older, or deeper than that scan, returns 404; list_conversions with explicit start_date and end_date still returns the row itself, just without the journey. You never supply a session id: the session is resolved server-side from the conversion, and no other session’s journey is reachable through this API.
get_attribution traps:
platformsrows do not sum tototals.total_conversions. Only conversions carrying at least one captured ad click can be attributed to a platform; organic, direct and email conversions are in the total and in no platform row.totals.attributed_sharetells you how much of the business the platform rows describe. When it is low, say so.time_to_convertbuckets by number of touchpoints, not elapsed time, despite the name. Itsbucketlabels read"1 touch","4-5 touches".linearconversions are fractional by construction, since credit is split across touchpoints.assisted_conversionsis what a last-click report hides: a platform whose first-click figure far exceeds its last-click figure is an introducer that last-click reporting undersells.
get_recovery answers “why does Google report fewer conversions than you do”. recovered_conversions is the gap; platform_would_report_conversions is what the pixel alone would have shown; by_reason splits it across ios_itp, cookie_expired, cookie_missing, ad_blocker and firefox_etp. Its denominators count only attributed conversions, those with a captured click id, so recovery_rate is the recovered share of attributable conversions, not of all conversions. Expect it to differ from anything you compute against the overview total.
Did it reach the ad platforms?
| Operation | Path | Scope | What it is for |
|---|---|---|---|
list_deliveries | GET /projects/{project}/delivery | delivery:read | Every attempt to send a conversion to an ad platform, newest first, with the failure reason in plain English. Filter by platform, status, conversion_id or since. |
get_delivery_summary | GET /projects/{project}/delivery/summary | delivery:read | Delivered, pending and failed counts, the delivery rate, the revenue sitting behind failed deliveries (value_at_risk), a per-platform breakdown and a daily failure count. Limit to one platform with platform=. |
retry_delivery | POST /projects/{project}/delivery/{id}/retry | delivery:write | Re-queue one failed delivery. {id} is the delivery attempt id from list_deliveries, not a conversion id. |
Google Ads and OpenAI Ads forwarding are live today. Meta, Microsoft Ads and TikTok forwarding are coming soon, so until they launch you will only see delivery rows for Google Ads and OpenAI Ads.
Use get_delivery_summary for “is our server-side tracking healthy” and to find the day a connection broke; list_deliveries to see which conversions failed and why.
- Rows are attempts, not conversions. One conversion sent to two platforms is two rows; a retried conversion is one row per attempt. Never count rows as conversions; that is what the summary is for. In the summary,
value_at_riskis deduplicated by conversion (one $200 order failing on two platforms is $200 at risk, not $400) while the counts deliberately are not. - Statuses are
pending,retrying,successandfailed.successmeans the platform accepted the upload, not that it attributed it to an ad. - Rows are dated by when the attempt was logged, which is shortly after the conversion itself, so a range edge can put a conversion and its delivery on different days.
- A conversion never sent to any platform produces no row at all and so appears nowhere in these figures. For those, use
list_conversionswithdelivery_status=not_forwarded. reasonis a plain-English sentence aimed at a marketer, never the platform’s raw error text. Only failures have one; a pending row has not been answered yet.get_delivery_summarymarks itselfpartial: truewith apartial_reasonwhen the range holds more rows than one scan covers. Every figure in a partial response under-reports. Narrow the range.
retry_delivery is the only write in this version, and it is not idempotent: each call queues one more attempt, so calling it twice sends twice. It is refused when the attempt is not failed, when no automatic attempts remain, and when the conversion has aged out of the platform’s own window: Google Ads and Microsoft Ads accept conversions up to 90 days after the click, Meta only 7. Past that you get 409 outside_retry_window, and no amount of retrying will change the platform’s answer.
Retry when the cause was a broken connection, a throttle or an unreachable platform and the underlying cause is now fixed. Do not retry when the platform said the click was not recognized, the conversion was outside its window, or it already has the conversion. Queueing is not delivery: check back with list_deliveries a few minutes later.
What did it cost?
| Operation | Path | Scope | What it is for |
|---|---|---|---|
get_spend | GET /projects/{project}/spend | spend:read | Ad cost from the connected platforms (Google Ads, Microsoft Ads and OpenAI Ads today), split by platform and campaign, with the revenue and conversions Convultra attributes to each. |
Check available first. When it is false, spend is unknown, and reason names the missing step: no ad platform connected, no ad account selected on a connected platform, or the nightly cost sync has not succeeded. Do not report that as zero spend; do not quote a ROAS at all.
When mixed_currency is true the platforms report in different currencies, total_spend is null, and you must quote the per-platform figures with their own currency. Convultra does not convert between currencies here. Inventing an exchange rate would silently corrupt every downstream total.
Campaign revenue and conversions are Convultra’s own attributed figures, joined on the click ids and campaign signals the click carried. They are not the platform’s reported conversions and will not match the ad platform UI exactly. get_recovery explains most of that difference.
Range and timezone
Fourteen of the 21 operations take a date range. Four of them bucket on UTC days regardless of the timezone you pass. See Date ranges and timezones.