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

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:

```sh
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

```sh
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.

```sh
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 | |
| --- | --- |
| `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.

```sh
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 | |
| --- | --- |
| `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:

```sh
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](https://odo.gg/docs/site-updates).

## Experiments

Make and run [experiments](https://odo.gg/docs/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:

```sh
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 | |
| --- | --- |
| `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](https://odo.gg/docs/experiments#what-odo-can-change-on-a-page)) 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](https://odo.gg/docs/experiments#experiments-odo-runs).

## Errors

Errors always look the same:

```json
{ "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, `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.
