AI assistants and APIREST API
AI assistants and API
REST API
Read a project’s numbers, send it events, payments and site updates, and run experiments from your server: keys, permissions, endpoints, errors and limits.
On this page
Your own servers can read a project’s numbers, send it events, payments and site updates, and run experiments, at https://odo.gg/api/v1. Every answer is JSON, in the project’s currency and time zone, and every money figure says how much of it odo checked itself.
Keys#
Make a key in Settings → API keys, in the project it’s for. Owners and admins of a project make, rename and revoke its keys, up to 20.
- The key (
odo_live_…) is shown once. odo keeps only its SHA-256 and its first few characters. - It reads and writes its own project only.
- It stops working the moment it’s revoked, or when the admin who made it leaves the project.
- It never appears in odo’s logs.
- Send it from a server, never from a web page: the API allows no other sites (no CORS), and anyone who can see a page can see a key in it.
Permissions#
A key does only what its permissions say. Pick them when you make it:
| Permission | Scope | Lets the key |
|---|---|---|
| Read | read | Earnings, visitors, channels and sales |
| Events | write:events | Server-side events and pageviews, for goals |
| Payments | write:payments | Payments odo can’t read itself, always Self-reported |
| Updates | write:updates | Notes of what changed on the site, marked on the charts |
| Experiments | write:experiments | Make and run experiments: they change what visitors see |
Authentication#
Send the key in the Authorization header, with every request:
curl https://odo.gg/api/v1/summary?range=30d -H "Authorization: Bearer $ODO_KEY"A key in the address is never read, and cookies don’t count: only the header.
Endpoints#
| Endpoint | Permission | What it does |
|---|---|---|
GET /summary | Read | Earnings, visitors, conversion and money per visitor, each with its verified share |
GET /sources | Read | Every source with its tier, badge, and what it earned today, this month and this year |
GET /channels | Read | Visitors, traced revenue, conversion and revenue per visitor for each channel |
GET /sales | Read | The latest payments, refunds and disputes, and the channel each was traced to |
POST /events | Events | Server-side events and pageviews |
POST /payments | Payments | Payments odo can’t read itself, always self-reported |
POST /updates | Updates | A site update: what changed on the site, and when |
GET /updates | Read or Updates | The range’s site updates, each with what it did |
GET /experiments | Read or Experiments | The project’s experiments with their status, verdict, decision and read-out, and those found in the site’s code |
GET /experiments/:id | Read or Experiments | One experiment whole, with what matched what, and the snippet for one in your code |
POST /experiments/suggest | Read or Experiments | Versions to try for a page and a goal: the facts, and odo’s analyst’s versions on Growth |
POST /experiments | Experiments | Makes an experiment: a draft, or running at once |
PATCH /experiments/:id | Experiments | Changes a draft (once it has run, only a few fields) |
POST /experiments/:id/action | Experiments | Starts, pauses, resumes, stops, ships or archives one |
Reads take these in the query:
| Parameter | Values | Default |
|---|---|---|
range | today, 7d, 30d, 90d or 12m, in the project’s time zone | 30d |
model | last or first: which visit a sale is credited to (channels and sales) | last |
limit | 1 to 100: how many sales | 20 |
Summary#
curl https://odo.gg/api/v1/summary?range=30d -H "Authorization: Bearer $ODO_KEY"{
"project": { "id": "…", "name": "Shop" },
"range": { "key": "30d", "from": "2026-09-14T22:00:00.000Z", "to": "2026-10-14T09:30:00.000Z", "timezone": "Europe/Berlin" },
"currency": "EUR",
"basis": "gross",
"earnings": { "value": 1240.5, "verified": 1100, "verifiedShare": 0.8868 },
"visitors": { "value": 8100, "verified": null, "verifiedShare": null },
"conversion": { "value": 0.012, "verified": 0.009, "verifiedShare": 0.75, "customers": 97 },
"perVisitor": { "value": 0.1531, "verified": 0.1358, "verifiedShare": 0.8868 },
"estimated": false,
"generatedAt": "2026-10-14T09:30:00.000Z"
}Earnings are verified when they come from a source with the Verified badge. A conversion is verified when the platform’s own records traced the sale to the visitor (Stripe, on a verified source); the page’s odo('Purchase') and API payments are self-reported. Visitors are counted by odo’s script, so there’s nothing to verify: null. basis is gross (what customers paid, after refunds) or net (after fees too), as the project counts. estimated is true when a very busy range was worked out from a sample.
Sources#
{
"project": { "id": "…", "name": "Shop" },
"currency": "EUR",
"basis": "gross",
"sources": [
{
"id": "…",
"name": "Stripe",
"platform": "stripe",
"platformName": "Stripe",
"kind": "payments",
"tier": "verified",
"verified": true,
"reason": null,
"business": "Racing Green Motors",
"status": "ok",
"lastSyncAt": "2026-10-14T09:29:12.000Z",
"earned": { "today": 98, "month": 1240.5, "year": 10320 }
}
],
"generatedAt": "2026-10-14T09:30:00.000Z"
}kind is payments for sources that report each sale, totals for daily totals (ad networks and the like). verified says whether the badge shows now; reason says why not when it doesn’t.
Channels#
GET /channels?range=7d&model=last lists every channel with visitors and visits, then what was traced to it:
{
"channel": "Organic Search",
"visitors": 1840,
"visits": 2215,
"customers": 31,
"revenue": 1519,
"verifiedRevenue": 1430,
"verifiedShare": 0.9414,
"conversion": 0.0168,
"perVisitor": 0.8255
}Sales#
GET /sales?range=7d&limit=10 lists the latest payments, refunds (negative) and disputes, newest first. Nothing about who bought:
{
"id": "ch_3Q…",
"at": "2026-10-14T08:02:11.000Z",
"kind": "payment",
"amount": 49,
"original": null,
"source": { "id": "…", "name": "Stripe", "platform": "stripe" },
"tier": "verified",
"verified": true,
"trace": { "channel": "Organic Search", "source": "Google", "verified": true }
}original has the amount as paid when that wasn’t the project’s currency. trace is null for a sale that wasn’t traced to a visit.
Events#
Send events your server knows about, or pageviews. Name the visitor with the site’s odo_vid cookie (best), or forward their ip and userAgent: those are hashed with the day’s salt, like a cookieless visit, and never kept.
curl -X POST https://odo.gg/api/v1/events -H "Authorization: Bearer $ODO_KEY" \
-H 'Content-Type: application/json' \
-d '{ "name": "Signup", "visitor": "<odo_vid>", "props": { "plan": "Pro" } }'{ "accepted": 1, "results": [{ "status": "accepted" }] }| Field | |
|---|---|
type | event (the default) or pageview |
name | The event’s name, as goals match it. Required for events. |
visitor | The visitor’s odo_vid. Or send ip and userAgent. |
url | The page. Required for pageviews; events default to the site’s home page. |
referrer | Where a pageview came from. |
props | Up to 30 properties: text up to 200 characters, numbers, true or false. |
site | The site’s id. Leave it out when the project has one site. |
An event joins the visitor’s visit, or their latest one: it never starts a visit, so a visitor the site has never seen is refused. A pageview does start one. Send up to 25 at once as { "events": [...] }: the answer is 202, with a result for each (accepted, ignored with its reason, or rejected with its code). When none is accepted, it’s an error instead, saying why.
Payments#
Payments land in the project’s API source, made with the first one: self-reported, never badged, and never in odo.gg’s verified total.
curl -X POST https://odo.gg/api/v1/payments -H "Authorization: Bearer $ODO_KEY" \
-H 'Content-Type: application/json' \
-d '{ "id": "inv_2026_0042", "amount": "49.00", "currency": "EUR", "visitor": "<odo_vid>" }'{
"source": { "id": "…", "name": "API", "tier": "self-reported", "verified": false },
"results": [{ "id": "inv_2026_0042", "status": "created", "trace": "traced" }]
}| Field | |
|---|---|
id | Required. Your id for it (the order or invoice number): the same id again never counts twice. |
amount | Required. In major units: 49 or "49.00". |
currency | Required. A code like USD. |
type | payment (the default) or refund. |
time | ISO 8601 with its zone, or epoch seconds or milliseconds. Now by default. |
fee | The processing fee, in the same currency. |
visitor | The buyer’s odo_vid, to trace it to their visit (with site when the project has several). |
Each result’s status is created, duplicate (that id again, unchanged) or updated (that id with new values: a correction, still one payment). trace is traced, unknown_visitor, unknown_site, site_required, no_site, or null without a visitor. The answer is 201 when anything was created, 200 otherwise. Up to 100 at once as { "payments": [...] }.
Site updates#
Note what changed on the site, from a deploy script or anywhere a key with Updates can run:
curl -X POST https://odo.gg/api/v1/updates -H "Authorization: Bearer $ODO_KEY" -H "Content-Type: application/json" -d '{"title":"New pricing page","path":"/pricing","kind":"pricing"}'{
"id": "…",
"projectId": "…",
"siteId": null,
"at": 1791970200000,
"local": "2026-10-14T11:30",
"title": "New pricing page",
"details": null,
"path": "/pricing",
"kind": "pricing",
"via": "key",
"by": "Deploy script",
"createdAt": 1791970200000,
"impact": null
}title is required, up to 120 characters. Optional: details (plain text, up to 1,000), path (/pricing or a full address), kind (deploy, design, copy, pricing or other), at (ISO 8601 with its zone, or epoch milliseconds; now by default) and site (an id, a domain or an address on it). GET /updates?range=30d&site=… lists them newest first, each with impact: what visitors, conversion and revenue did after it. More in site updates.
Experiments#
Make and run experiments with a key that has Experiments, as the dashboard’s form does. Making one that starts at once, with a change odo makes on the page:
curl -X POST https://odo.gg/api/v1/experiments -H "Authorization: Bearer $ODO_KEY" \
-H 'Content-Type: application/json' \
-d '{ "name": "Pricing headline", "path": "/pricing", "goal": { "kind": "event", "name": "Signup" },
"variants": [ { "label": "Original" },
{ "label": "Try free", "changes": [ { "type": "text", "selector": "h1", "value": "Try it free" } ] } ],
"start": true }'The answer is 201, with the experiment whole:
{
"generatedAt": 1792047600000,
"currency": "EUR",
"experiment": {
"id": "…",
"projectId": "…",
"siteId": "…",
"domain": "example.com",
"name": "Pricing headline",
"mode": "content",
"path": "/pricing",
"hypothesis": null,
"goal": { "kind": "event", "name": "Signup" },
"goalLabel": "“Signup” events",
"variants": [
{ "key": "control", "label": "Original", "description": null, "changes": [] },
{ "key": "b", "label": "Try free", "description": null, "changes": [{ "type": "text", "selector": "h1", "value": "Try it free" }] }
],
"weights": null,
"status": "running",
"autoPromote": true,
"maxDays": 28,
"startedAt": 1792047600000,
"endedAt": null,
"shippedVariant": null,
"decision": null,
"readout": null,
"held": null,
"via": "key",
"by": "Deploy script",
"createdAt": 1792047600000,
"updatedAt": 1792047600000,
"result": null
},
"segments": null,
"snippet": null,
"ai": { "on": true, "reason": null }
}| Field | |
|---|---|
name | Required. Letters, digits, spaces and _ - . :, up to 60 characters, and not another experiment’s on the site (archived ones too). For one in your code, exactly the name your code passes. |
mode | content (the default): odo changes the page. code: your site’s code shows the variants. |
path | The pages it runs on: /pricing, /blog/*, or * (the default) for every page. |
goal | What it’s judged by: { "kind": "sales" } (the default), { "kind": "goal", "id": "…" } for one of the project’s goals, { "kind": "event", "name": "Signup" } or { "kind": "page", "path": "/thanks" }. |
variants | Required. 2 to 5, the original first. Each may have a key (its name in exposures, exactly what your code passes; control, b, c… unless you say), a label (up to 60 characters), a description (up to 300) and, in content mode, its changes: none for the original, 1 to 10 for each other. |
weights | One number per variant, 0 to 1,000, not all zero: [3, 1] sends three in four to the original. Equal unless you say. |
hypothesis | What you expect, and why: up to 500 characters. |
autoPromote | Show a content winner to everyone once odo calls it. true unless you say. |
maxDays | 7 to 90: the days it runs before odo calls it without a winner (no difference). 28 unless you say. |
start | true starts it at once; otherwise it waits as a draft. If it can’t start, it’s kept as a draft and the error says why. |
site | The website: its id or domain. Leave it out when the project has one. |
Each of a variant’s changes:
type | Takes |
|---|---|
text | selector, and value: the new text, up to 500 characters |
attr | selector, attribute (href, src, alt, title, aria-label or placeholder) and value |
hide, show | selector |
style | selector, property (one of the eleven odo sets) and value |
A change odo wouldn’t make is a 422, with fields naming it and saying why: { "variants.1.changes.0": "Only https:// addresses, or pages of the site (/pricing)" }.
The rest:
GET /experiments?site=…lists the project’s experiments (sitenarrows them to one website), newest first and archived ones last, each with itsresult(the verdict and every variant’s numbers since it started),decisionandreadout; and infound, up to 10 experiments the site’s code ran in the last 90 days that nobody registered.GET /experiments/:idis one whole, as above, withsegments(what matched what) and, for one in your code,snippet: the call and the markup to paste.PATCH /experiments/:idtakes any of the fields above butsiteandstartwhile it’s a draft. Once it has run, onlyhypothesis,autoPromoteandmaxDays: anything else is a409.POST /experiments/:id/actiontakes{ "action": "start" }, orpause,resume,stop,shiporarchive.shiptakes thevariant(its key), else the one odo called the winner. An action its status doesn’t allow is a409.POST /experiments/suggesttakes the page (path,*by default), an element’sselectorand what it says now (current), thegoal, ahypothesis, themodeand how many versions besides the original (count, 1 to 4, 2 by default). It answers withfacts(the page’s last 30 days, what people click there, past experiments) and, on Growth,suggestions: odo’s analyst’s versions, each change checked, with anexperimentready to send toPOST /experiments. Without them,notesays why.
An experiment made with a key has "via": "key" and the key’s name as by. A website runs up to 20 at once and keeps up to 200, archived ones included. More in experiments.
Errors#
Errors always look the same:
{ "error": { "code": "validation_failed", "message": "An amount like 49.00", "fields": { "amount": "An amount like 49.00" } } }| Status | Code | When |
|---|---|---|
| 400 | bad_json | The body isn’t valid JSON |
| 401 | unauthorized | No key was sent |
| 401 | invalid_key | It isn’t a key, or there’s no such key |
| 401 | key_revoked | It was revoked, or whoever made it no longer runs the project |
| 402 | payment_required | The project’s plan has lapsed: reads wait for one |
| 403 | insufficient_scope | The key lacks the permission this needs |
| 404 | not_found | No such endpoint, or no such site or experiment in the project |
| 409 | source_paused, too_many_sources | The API source is paused in odo, or the account can’t have another source |
| 409 | conflict | An experiment’s name is taken on the site, the site has as many experiments as it can, or the change or action doesn’t fit the experiment’s status |
| 413 | too_large, too_many | The body is too big, or holds too many events or payments |
| 415 | unsupported_media_type | The body isn’t sent as application/json |
| 422 | validation_failed | Something in it is wrong: fields says what, by its path (payments.1.amount) |
| 422 | unknown_visitor, unknown_site, site_required, no_site, wrong_host | No event in it could be counted, and this is why |
| 429 | rate_limited | Too many requests: wait as Retry-After says. From /experiments/suggest: odo’s analyst has done enough for this hour |
| 503 | busy | odo is busy for a moment: send it again shortly |
Limits#
- 120 requests a minute per key, and 1,200 events and payments. Each answer says what’s left in
X-RateLimit-Remaining; past it,429withRetry-After. - Bodies up to 32 KB for events, 64 KB for payments and experiments, and 16 KB for site updates, as JSON.
- 30 failed key checks a minute from one address, then
429. A working key is never held up by someone else’s failures.
When a project’s plan has lapsed, reads wait for a new one (402), and so do experiments, made or read. Payments keep being taken.