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:

PermissionScopeLets the key
ReadreadEarnings, visitors, channels and sales
Eventswrite:eventsServer-side events and pageviews, for goals
Paymentswrite:paymentsPayments odo can’t read itself, always Self-reported
Updateswrite:updatesNotes of what changed on the site, marked on the charts
Experimentswrite:experimentsMake and run experiments: they change what visitors see

Authentication#

Send the key in the Authorization header, with every request:

Shell
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#

EndpointPermissionWhat it does
GET /summaryReadEarnings, visitors, conversion and money per visitor, each with its verified share
GET /sourcesReadEvery source with its tier, badge, and what it earned today, this month and this year
GET /channelsReadVisitors, traced revenue, conversion and revenue per visitor for each channel
GET /salesReadThe latest payments, refunds and disputes, and the channel each was traced to
POST /eventsEventsServer-side events and pageviews
POST /paymentsPaymentsPayments odo can’t read itself, always self-reported
POST /updatesUpdatesA site update: what changed on the site, and when
GET /updatesRead or UpdatesThe range’s site updates, each with what it did
GET /experimentsRead or ExperimentsThe project’s experiments with their status, verdict, decision and read-out, and those found in the site’s code
GET /experiments/:idRead or ExperimentsOne experiment whole, with what matched what, and the snippet for one in your code
POST /experiments/suggestRead or ExperimentsVersions to try for a page and a goal: the facts, and odo’s analyst’s versions on Growth
POST /experimentsExperimentsMakes an experiment: a draft, or running at once
PATCH /experiments/:idExperimentsChanges a draft (once it has run, only a few fields)
POST /experiments/:id/actionExperimentsStarts, pauses, resumes, stops, ships or archives one

Reads take these in the query:

ParameterValuesDefault
rangetoday, 7d, 30d, 90d or 12m, in the project’s time zone30d
modellast or first: which visit a sale is credited to (channels and sales)last
limit1 to 100: how many sales20

Summary#

Shell
curl https://odo.gg/api/v1/summary?range=30d -H "Authorization: Bearer $ODO_KEY"
JSON
{
  "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#

JSON
{
  "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:

JSON
{
  "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:

JSON
{
  "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.

Shell
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" } }'
JSON
{ "accepted": 1, "results": [{ "status": "accepted" }] }
Field
typeevent (the default) or pageview
nameThe event’s name, as goals match it. Required for events.
visitorThe visitor’s odo_vid. Or send ip and userAgent.
urlThe page. Required for pageviews; events default to the site’s home page.
referrerWhere a pageview came from.
propsUp to 30 properties: text up to 200 characters, numbers, true or false.
siteThe 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.

Shell
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>" }'
JSON
{
  "source": { "id": "…", "name": "API", "tier": "self-reported", "verified": false },
  "results": [{ "id": "inv_2026_0042", "status": "created", "trace": "traced" }]
}
Field
idRequired. Your id for it (the order or invoice number): the same id again never counts twice.
amountRequired. In major units: 49 or "49.00".
currencyRequired. A code like USD.
typepayment (the default) or refund.
timeISO 8601 with its zone, or epoch seconds or milliseconds. Now by default.
feeThe processing fee, in the same currency.
visitorThe 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:

Shell
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"}'
JSON
{
  "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:

Shell
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:

JSON
{
  "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
nameRequired. 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.
modecontent (the default): odo changes the page. code: your site’s code shows the variants.
pathThe pages it runs on: /pricing, /blog/*, or * (the default) for every page.
goalWhat 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" }.
variantsRequired. 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.
weightsOne number per variant, 0 to 1,000, not all zero: [3, 1] sends three in four to the original. Equal unless you say.
hypothesisWhat you expect, and why: up to 500 characters.
autoPromoteShow a content winner to everyone once odo calls it. true unless you say.
maxDays7 to 90: the days it runs before odo calls it without a winner (no difference). 28 unless you say.
starttrue 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.
siteThe website: its id or domain. Leave it out when the project has one.

Each of a variant’s changes:

typeTakes
textselector, and value: the new text, up to 500 characters
attrselector, attribute (href, src, alt, title, aria-label or placeholder) and value
hide, showselector
styleselector, 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 (site narrows them to one website), newest first and archived ones last, each with its result (the verdict and every variant’s numbers since it started), decision and readout; and in found, up to 10 experiments the site’s code ran in the last 90 days that nobody registered.
  • GET /experiments/:id is one whole, as above, with segments (what matched what) and, for one in your code, snippet: the call and the markup to paste.
  • PATCH /experiments/:id takes any of the fields above but site and start while it’s a draft. Once it has run, only hypothesis, autoPromote and maxDays: anything else is a 409.
  • POST /experiments/:id/action takes { "action": "start" }, or pause, resume, stop, ship or archive. ship takes the variant (its key), else the one odo called the winner. An action its status doesn’t allow is a 409.
  • POST /experiments/suggest takes the page (path, * by default), an element’s selector and what it says now (current), the goal, a hypothesis, the mode and how many versions besides the original (count, 1 to 4, 2 by default). It answers with facts (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 an experiment ready to send to POST /experiments. Without them, note says 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:

JSON
{ "error": { "code": "validation_failed", "message": "An amount like 49.00", "fields": { "amount": "An amount like 49.00" } } }
StatusCodeWhen
400bad_jsonThe body isn’t valid JSON
401unauthorizedNo key was sent
401invalid_keyIt isn’t a key, or there’s no such key
401key_revokedIt was revoked, or whoever made it no longer runs the project
402payment_requiredThe project’s plan has lapsed: reads wait for one
403insufficient_scopeThe key lacks the permission this needs
404not_foundNo such endpoint, or no such site or experiment in the project
409source_paused, too_many_sourcesThe API source is paused in odo, or the account can’t have another source
409conflictAn 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
413too_large, too_manyThe body is too big, or holds too many events or payments
415unsupported_media_typeThe body isn’t sent as application/json
422validation_failedSomething in it is wrong: fields says what, by its path (payments.1.amount)
422unknown_visitor, unknown_site, site_required, no_site, wrong_hostNo event in it could be counted, and this is why
429rate_limitedToo many requests: wait as Retry-After says. From /experiments/suggest: odo’s analyst has done enough for this hour
503busyodo 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, 429 with Retry-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.

This page as markdownllms.txt