Using the API
Keys, the answer format, rate limits, paging and errors — with copy-paste examples in curl, Node and Python.
The API is the same one the dashboard runs on: anything you can see or change on screen, a script can too. It is on the Growth, Pro and Scale plans.
Every endpoint, with its fields, is in the API reference. For
Postman, Insomnia or a code generator, import
https://app.utmcap.com/api/v1/openapi.json — it describes every call an API
key can make, and a test in our build fails if it ever disagrees with the code.
#Keys
Make one under Settings → API. Send it on every call:
Authorization: Bearer utmk_…
A Read only key can read everything and is refused any change with
403 read_only_key — the one to give a spreadsheet, a BI tool or somebody
else's script. Keys can expire and can be rotated; see
Your account. Treat a key as a password.
#Answers
Every answer is JSON in the same envelope:
{ "data": { "rows": [] }, "error": null }
and every failure:
{ "data": null, "error": { "message": "This API key is read-only", "code": "read_only_key" } }
Branch on code; show message to a person. The codes every call can meet:
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_body, invalid_input |
A body that is not JSON, or a value such as an id in the wrong form |
| 401 | unauthenticated |
No key, or a key that is revoked or expired |
| 402 | plan_limit |
The plan has no API, or a plan limit was reached |
| 403 | read_only_key |
A read-only key tried to change something |
| 429 | rate_limited |
This minute's calls are used up — wait Retry-After seconds |
| 500 | internal_error |
Something failed on our side; a read is safe to retry |
Each endpoint's own codes are listed with it in the reference.
#Rate limits
Calls are counted per account, all its keys together, in one-minute windows: 300 a minute on Growth, 600 on Pro and 1,200 on Scale. Every answer says where you stand:
RateLimit-Limit: 300
RateLimit-Remaining: 299
RateLimit-Reset: 42
RateLimit-Reset is the seconds until the minute starts again. Past the limit
the answer is 429 with Retry-After.
#Retrying a create
Send an Idempotency-Key header on a POST and a retry with the same key gets
the first answer back instead of creating twice. How it works, and what we
promise about the API not changing, is on
API stability and changes.
#Lists and paging
Lists come 500 rows at a time. Ask for fewer with ?limit= (1–500). While more
remain the answer has a next_cursor; send it back as ?cursor= and stop when
it is null.
#Dates
Reports take ?from= and ?to= as YYYY-MM-DD or YYYY-MM-DD HH:MM:SS, in
UTC. A bare to date means the end of that day. Left out, the window is the
last seven days including today.
#Examples
List your campaigns with this week's figures.
curl
curl "https://app.utmcap.com/api/v1/campaigns?from=2026-09-01&to=2026-09-07" \
-H "Authorization: Bearer $UTMCAP_KEY"
Node (18 or later, no packages)
const key = process.env.UTMCAP_KEY;
const base = 'https://app.utmcap.com/api/v1';
async function call(path) {
const res = await fetch(base + path, { headers: { Authorization: `Bearer ${key}` } });
const body = await res.json();
if (res.status === 429) {
const wait = Number(res.headers.get('Retry-After') ?? 60);
await new Promise((r) => setTimeout(r, wait * 1000));
return call(path);
}
if (body.error) throw new Error(`${body.error.code}: ${body.error.message}`);
return body.data;
}
// Every offer, however many pages that takes.
const offers = [];
let cursor = null;
do {
const page = await call(`/offers${cursor ? `?cursor=${cursor}` : ''}`);
offers.push(...page.rows);
cursor = page.next_cursor;
} while (cursor);
console.log(offers.length, 'offers');
Python (3.8 or later, with requests)
import os, time, requests
KEY = os.environ["UTMCAP_KEY"]
BASE = "https://app.utmcap.com/api/v1"
def call(path, params=None):
res = requests.get(BASE + path, params=params, headers={"Authorization": f"Bearer {KEY}"})
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", 60)))
return call(path, params)
body = res.json()
if body["error"]:
raise RuntimeError(f'{body["error"]["code"]}: {body["error"]["message"]}')
return body["data"]
report = call("/reports/performance", {"dimension": "country", "from": "2026-09-01", "to": "2026-09-07"})
for row in report["rows"]:
print(row["dims"][0], row["clicks"], row["revenue"])
#What is not in the API
Signing in, billing, the team, support conversations and managing API keys themselves need a person in the dashboard. The tag, goals fired from your pages and postbacks have their own pages: the tag, goals and postbacks.