Skip to content

API overview

Getting started with the Convultra REST API and MCP server, authentication, sk_ versus proj_ keys, response shape, rate limits and caching.

article |9 min |Updated Sep 2026
On this page · 7 sections

The Convultra API and MCP server are in Beta. They give you two programmatic ways to read the conversions Convultra records, including the ones browser pixels lose: the REST API at https://data.convultra.com/v1 for warehouses, BI tools, scripts and cron jobs, and the MCP server at https://mcp.convultra.com/mcp for Claude, ChatGPT, Cursor and your own agents. Both expose the same 21 operations, run the same code and return the same numbers. An MCP tool result and a REST response to the same question are the same answer, differently wrapped.

The machine-readable reference is the OpenAPI document, which needs no credential:

curl -s https://data.convultra.com/v1/openapi.json | jq '.paths | keys'

It describes every parameter, type and response shape. It cannot tell you what a metric means, why a figure is null, or which scope to ask for. That is what these articles are for. Where the two overlap, the OpenAPI document is authoritative on shape and this help center on meaning. The full reference is also published at /docs/api.

Getting started

1. Create a key

In the dashboard go to Settings → Developer → Create key. Give it a name, tick the scopes the integration needs, choose which projects it may read, and pick an expiry: 30, 90 or 365 days, or never. 90 is the default.

The key is shown once, at creation. Only a SHA-256 hash is stored, so nobody, including Convultra support, can recover it later. If you lose it, revoke it and make another.

2. Make a request

Send the key as a bearer token:

curl -s https://data.convultra.com/v1/me \
  -H "Authorization: Bearer sk_your_key_here" | jq

/me is the right first call. It needs no scope, and it tells you what the key is, what it holds and which projects it can reach.

{
  "data": {
    "type": "api_key",
    "name": "Warehouse sync",
    "scopes": ["analytics:read", "conversions:read", "delivery:read", "projects:read", "spend:read"],
    "plan": "pro",
    "rate_limit": { "requests_per_minute": 300, "requests_per_day": 50000 },
    "projects": [
      {
        "id": "8f14e45f-ea0b-4f1c-9a3d-2b7c5e6d1a90",
        "key": "proj_live_9aKq2mWx7Tb4Rn8s",
        "name": "Acme Store",
        "domain": "acme.example",
        "currency": "GBP",
        "timezone": "Europe/London"
      }
    ]
  },
  "meta": {
    "request_id": "3f0b8c2e-5d41-4a77-9e15-6c0b2d8f4a31",
    "generated_at": "2026-09-11T09:14:22.108Z",
    "cached": false
  }
}

3. Ask a real question

Take the project id, or its domain, and read the headline figures for the last 30 days:

curl -s "https://data.convultra.com/v1/projects/acme.example/overview?range=30d&compare=previous_period" \
  -H "Authorization: Bearer sk_your_key_here" | jq '.data.current, .meta.range'

Rules that apply to every request

Never put a credential in a URL. Query parameters named token, api_key, apiKey, access_token or key are refused with 400 invalid_request before authentication is attempted, because URLs end up in server logs, proxies and browser history.

# Refused with 400 invalid_request, deliberately.
curl -s "https://data.convultra.com/v1/me?api_key=sk_your_key_here"

The base URL includes /v1. Use https://data.convultra.com/v1. The OpenAPI servers block lists a second entry on the app domain for environments where a firewall pins one host; unless you have that problem, use data.convultra.com. The /v1/* namespace on tracking.convultra.com and on customer tracking domains is the tracking ingest, not this API.

The dashboard session cookie is not accepted. This surface is for machines. Only an sk_ key or an OAuth access token authenticates.

CORS is open. The API is credentialed by bearer token and never by cookie, so there is no ambient authority for a hostile page to borrow, and browser-based BI tools can call it directly. Access-Control-Allow-Origin: *, and the rate-limit, ETag, Retry-After, X-Request-Id and Convultra-Api-Version headers are exposed.

Authentication

There are two kinds of credential, for two kinds of client.

sk_ API keys: server to server

sk_ followed by 40 characters, created under Settings → Developer, scoped to a set of permissions and optionally to a set of projects, with an optional expiry.

Authorization: Bearer sk_your_key_here

Use these for warehouse syncs, scheduled reports, internal dashboards, and for MCP clients that authenticate with a static header (Claude Code, Cursor, your own agents).

A key belongs to an account or an agency, never to a person. It keeps working when the person who created it leaves, and it cannot be minted with more access than its owner has. See API keys and agency access.

OAuth 2.1: MCP clients acting for a user

Claude.ai, ChatGPT and other hosted assistants sign the user in rather than holding a key. Convultra runs an OAuth 2.1 authorization server, and the client discovers everything itself:

  1. The client calls the MCP endpoint with no credential and gets 401 with a WWW-Authenticate: Bearer ..., resource_metadata="..." challenge.
  2. It fetches https://mcp.convultra.com/.well-known/oauth-protected-resource/mcp to learn which authorization server to use.
  3. It fetches that server’s /.well-known/oauth-authorization-server metadata.
  4. The user signs in, sees a consent screen, and approves a set of scopes and projects.
  5. The client receives an access token (prefixed cvt_) and a refresh token.

PKCE with S256 is mandatory, and the token is bound to the MCP URL as its audience. Access tokens are accepted on the MCP surface; you never create, copy or paste them. See Connect Claude.

proj_ is not an API key

This is the mistake almost everyone makes once. proj_... is your project tracking key. It is rendered into every visitor’s browser by the tracking script, so it is public, and it is write-only: it can record events and nothing else. It cannot read data, and the API says so rather than returning a bare 401:

{
  "error": {
    "code": "invalid_token",
    "message": "That is a project tracking key (proj_…), which is public and write-only. Create a secret API key (sk_…) under Settings → Developer.",
    "doc_url": "https://convultra.com/docs/api/errors#invalid_token"
  }
}

One nuance: a proj_... key is a valid way to name a project in a path. /v1/projects/proj_live_9aKq2mWx7Tb4Rn8s/overview works. It is only invalid as a credential.

Response shape

Success:

{
  "data": { },
  "meta": {
    "request_id": "3f0b8c2e-5d41-4a77-9e15-6c0b2d8f4a31",
    "generated_at": "2026-09-11T09:14:22.108Z",
    "cached": false,
    "project_id": "8f14e45f-ea0b-4f1c-9a3d-2b7c5e6d1a90",
    "currency": "GBP",
    "range": { "start_date": "2026-08-13", "end_date": "2026-09-11", "timezone": "Europe/London" }
  }
}

Paged operations (get_breakdown, list_conversions, list_deliveries) add a sibling page block:

"page": { "next_cursor": "eyJvZmZzZXQiOjI1fQ", "has_more": true, "limit": 25 }

There is no success boolean; the HTTP status carries that. project_id, currency and range appear only where they apply. cached tells you whether the payload came from the response cache.

Paging. Treat next_cursor as opaque and pass it back verbatim as cursor=. Keep going while has_more is true; stop when it is false. Never construct a cursor yourself.

Headers on every response: X-Request-Id (quote it in a support conversation), Convultra-Api-Version: 2026-09-01, Cache-Control: private, max-age=60, and a weak ETag on reads.

CSV. Send Accept: text/csv to get a CSV file instead of JSON. It applies only where the payload is a flat list, which today means list_projects and list_events; every other operation returns a nested object and keeps returning JSON. Values a spreadsheet would read as a formula are prefixed with an apostrophe, because campaign names and page paths are influenced by whoever hits a tracked URL.

Errors

Every failure has the same shape, and the HTTP status is always the signal. code is stable and safe to branch on; message is written for a developer. Three statuses answer three different questions:

  • 401: we do not know who you are. Send a valid credential.
  • 403: we know who you are, and the answer is no. Change the credential’s permissions, not the request.
  • 404: there is nothing here for you. That includes a project that exists but your credential cannot reach, which returns 404, never 403, so the API never confirms which project ids exist.

Honor Retry-After on 429; retry 500 and 502 with backoff and keep the X-Request-Id. Every code is in API errors.

Rate limits, concurrency and caching

Limits protect a shared resource: the query capacity behind the API is the capacity the dashboard draws on. Rate and concurrency are bounded separately. A client inside its per-minute allowance can still fire every request in the same instant; the concurrency limit stops that.

PlanRequests a minuteRequests a dayConcurrent requestsKeysAgency-wide keys
Basic (Starter)605,00023No
Pro (Growth and Scale)30050,000410No
Agency600100,000625Yes
Enterprise1,000Unlimited850Yes

There is no free plan. GET /me returns the limits in force for your own credential, including any per-account arrangement, so read them from there rather than from this table. Unrecognized plans fall back to Basic limits rather than to no access.

Headers. Successful responses carry:

X-RateLimit-Limit: 300
X-RateLimit-Remaining: 287
X-RateLimit-Reset: 1788172800

X-RateLimit-Reset is a Unix timestamp. A 429 adds Retry-After in seconds. A 429 caused by concurrency rather than rate also carries X-RateLimit-Scope: concurrency. The two have different fixes: slow down overall, or stop sending everything at once. If the rate-limit headers are absent, the limiter could not be consulted and the request was served anyway; it does not mean you have no limit.

Honor Retry-After. It is the only delay that is certainly long enough. A concurrency 429 asks for one second; a per-minute 429 asks for the remainder of the minute; a per-day 429 asks for the time to midnight UTC. Retrying sooner fails again and consumes quota doing it.

Poll politely. Pull on the schedule the data changes on: cost data syncs nightly, and a closed date range cannot change at all. Prefer one wide request to many narrow ones; get_timeseries over 90 days is one request, not 90. Use since= on list_conversions and list_deliveries for incremental pulls.

Caching. Read responses carry a weak ETag. Send it back as If-None-Match and an unchanged payload answers 304 Not Modified with an empty body, which costs you nothing:

curl -s -o /dev/null -D - "https://data.convultra.com/v1/projects/acme.example/overview?range=30d" \
  -H "Authorization: Bearer sk_your_key_here" \
  -H 'If-None-Match: W/"4f1c9a3d2b7c5e6d1a908f14e45fea0b"'

Responses are also cached server-side for 60 seconds while the range includes today, and for an hour once the range has closed. meta.cached tells you which you got. The cache is keyed by question and scopes, never by credential, so two keys asking the same question share a hit, and a cache hit does not consume a concurrency slot. Removing a conversion invalidates the affected project’s cached responses.

Where to go next

Next in Developers API scopes →