# For agents (/docs/agents)
These docs are written for agents as well as people. The same rules appear in three places so an agent gets them whichever way it arrives.
## Machine-readable entry points [#machine-readable-entry-points]
| Resource | URL |
| ------------------------ | -------------------------------------------------------------------------------------------- |
| Page index | [`/llms.txt`](/llms.txt) |
| Every page, concatenated | [`/llms-full.txt`](/llms-full.txt) |
| Any page as Markdown | append `.md` to its path, for example `/docs/quickstart.md`, or send `Accept: text/markdown` |
| The contract | [`https://api.ongoing.ai/v1/openapi.json`](https://api.ongoing.ai/v1/openapi.json) |
## Operation notes in the contract [#operation-notes-in-the-contract]
Every operation in the OpenAPI document carries three vendor extensions:
| Extension | Content |
| ---------------- | ----------------------------------------------------------------------------------- |
| `x-scope` | The scope the key needs, or `any` / `none`. |
| `x-billing-unit` | The unit a successful call consumes, or `free`. |
| `x-agent-notes` | When to call it, what it costs, side effects, and how to read the result correctly. |
The MCP server (later release) builds its tool descriptions from `x-agent-notes`, so the tool an agent sees and the page a person reads are the same text.
## Rules that prevent wrong conclusions [#rules-that-prevent-wrong-conclusions]
1. A `404` or `no_match` means no profile was returned; it does not say why. Read `reason` first. `not_in_corpus` is missing coverage, and offering an observation (`refresh: true`, needs `write:scans`) is the right response. `not_ecommerce` is the one case that is a statement about the site: we observed it and classified it as not a store. Never report either as "this is not a store" without naming which one it was.
2. A technology missing from `technologies` was not seen within what that observation covered. Only a `technology_removed` signal or an `install_state` of `inactive` is a confirmed removal, and both require three consecutive comparable misses. Do not report a single absence as a vendor being dropped.
3. `last_observed_at`-style fields describe the observation, not the change. `effective_at` on a signal is the estimate of when the change happened.
4. `stale` results are complete and billed. Do not discard them; report the observation date.
5. Every `POST` you might retry needs an `Idempotency-Key` derived from a stable identifier, or a timeout can bill twice.
6. Read `GET /v1/account` before a bulk run and keep within `plan.limits`; compare `period.included` against `period.used` before spending.
7. Quote `request_id` when reporting a failure to a person.
## Test the rules [#test-the-rules]
The acceptance test for these docs is a fresh agent session with a test key that completes: validate the key, read one store, enrich a batch, explain a `no_match` correctly, and recover from a `403 forbidden_scope` by naming the missing scope. If any step needs information that is not in `llms-full.txt` or the contract, that is a documentation bug; report it.
# Authentication (/docs/authentication)
Every request to `/v1` carries a workspace API key as a bearer token, except the four public routes: the service index `/`, the version index `/v1`, health at `/v1/health`, and the contract at `/v1/openapi.json`.
```http
Authorization: Bearer oai_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v
```
## Keys belong to workspaces [#keys-belong-to-workspaces]
A key is owned by a workspace, not a person. Usage, limits, and billing accrue to the workspace; the key records who created it. Workspace admins create and revoke keys under **Settings → API keys** in the dashboard. Members can see usage but not manage keys.
Create one key per consumer. "Clay production", "Zapier", and "Local testing" as three keys means you can revoke one without touching the others and read usage per consumer on the [usage endpoint](/docs/reference/account/get-usage).
## Key format [#key-format]
| Prefix | Meaning |
| ----------- | --------------------------------------------------------------------------------------------------- |
| `oai_live_` | Production key. Reads live data and can queue scans when the scope allows. |
| `oai_test_` | Reserved for the test tenant, which arrives in a later release. Keys created today are `oai_live_`. |
The key tag plus the first four characters of the secret (for example `oai_live_a1b2`) are the key prefix. They are safe to log and appear in the dashboard and in `GET /v1/account`. The remainder is the secret and is shown once at creation.
## Scopes [#scopes]
A key carries a set of scopes chosen at creation. Calling an operation outside them returns `403 forbidden_scope`.
| Scope | Grants |
| ------------------------------------- | ----------------------------------------------------- |
| `read:stores` | Store profiles, enrich without refresh, technologies |
| `read:signals` | History, signal search, event polling (later release) |
| `write:scans` | `refresh: true` on enrich, explicit scan requests |
| `read:watchlists`, `write:watchlists` | Watchlist and list operations (later release) |
| `read:webhooks`, `write:webhooks` | Webhook subscriptions and deliveries (later release) |
| `read:jobs` | Job status and results (later release) |
The dashboard offers three presets: read only, read and watch, and full. Partner integrations get a fixed preset. Check `key.scopes` on `GET /v1/account` if a call returns `403`.
## Revocation and rotation [#revocation-and-rotation]
Revoking takes effect in the dashboard at once, and every API instance rejects the key with `401 unauthorized` within 60 seconds. That window is a credential cache, so plan a rotation around it rather than assuming the old key dies on the same request. Revoked keys stay listed in the dashboard for 30 days so usage reports still reconcile.
To rotate, create the new key, deploy it, confirm traffic on the new prefix in the usage view, then revoke the old one. Keys can carry an expiry at creation for credentials you hand to a contractor or a trial.
## Keep keys out of browsers [#keep-keys-out-of-browsers]
Keys grant workspace-level access and must stay server-side. A browser extension, a spreadsheet add-on, or a no-code tool should call through a backend you control or use the vendor's credential store (Clay, Zapier, and Make all store the key encrypted and send it on your behalf). Never embed a live key in client-side JavaScript.
If a key leaks, revoke it in the dashboard; the leaked key stops working within the same 60-second window. Usage from the leaked key stays attributed to its prefix so you can see what was read.
# Billing units and plans (/docs/billing)
Usage is metered in four units. Every billable request writes its units to the workspace ledger before the response returns, and that ledger is what you are charged from. Reporting is a read of it: `GET /v1/usage` and the dashboard can trail the ledger by up to five minutes, so a total taken seconds after a bulk run may still be climbing.
| Unit | Consumed by | Amount |
| ------------------- | --------------------------------------------------------------- | ---------------------------------- |
| `lookup_cached` | A `matched` or `stale` store profile from existing observations | 1 per store |
| `lookup_live` | A scan queued by `refresh: true` | 1 per queued domain, at queue time |
| `history_query` | One page of store history or signal search (later release) | 1 per page |
| `refresh_requested` | Reserved for explicit scan requests (later release) | 1 per domain |
## What is never billed [#what-is-never-billed]
* A `no_match` result, whatever the reason. Unknown domains cost nothing to ask about. Queueing a scan for one is a separate charge: with `refresh: true`, the lookup stays free and each queued domain costs a `lookup_live` unit at queue time.
* A `404 not_found` on `GET /v1/stores/{domain}`.
* Any error response (4xx or 5xx).
* An idempotent replay (`Idempotent-Replayed: true`).
* `GET /v1/account`, `GET /v1/usage`, `GET /v1/technologies`, and `GET /v1/health`.
A `stale` profile is billed like a fresh one: the data is real, just older than the plan's freshness window, and the `stale` flag lets you decide whether a refresh is worth a `lookup_live` unit.
## Plan limits [#plan-limits]
Limits are enforced from the workspace plan at request time and returned on `GET /v1/account` as `plan.limits`. The table below is the current default per plan; the values on the account endpoint are authoritative for your key.
Enrich batch is the per-request domain cap for your plan, returned as `plan.limits.enrich_batch_max`. 100 is the contract maximum, not a universal default.
| Plan | RPM | Concurrency | Enrich batch | Included `lookup_cached` / period | Freshness window | Live refreshes / period |
| ------- | ---------- | ----------- | ------------ | --------------------------------- | ---------------- | ----------------------- |
| Free | 30 | 2 | 25 | 25 | 30 days | 0 |
| Pro | 120 | 5 | 100 | plan-defined | 30 days | 100 |
| Growth | 300 | 10 | 100 | 10,000 | 30 days | plan-defined |
| Partner | negotiated | negotiated | 100 | metered | negotiated | metered |
When the included allowance is used up, non-partner keys receive `402 payment_required` with a `reset_at` timestamp. Partner keys continue and are metered.
## Reading your usage [#reading-your-usage]
`GET /v1/usage` returns the current period totals and a per-key breakdown, including revoked keys that consumed units this period so the numbers reconcile. As above, these totals are a report and can trail the ledger by up to five minutes. Every response also carries `X-Usage-Remaining` for the `lookup_cached` unit, so a bulk run can stop before it hits the wall.
Prices per unit are on the [pricing page](https://ongoing.ai/pricing). This page describes what a unit is, not what it costs.
# Connect with a client (/docs/clients)
Four things make a client well behaved: send the key as a bearer token, read `GET /v1/account` first to learn its limits, send an `Idempotency-Key` on any request you might retry, and honor `Retry-After` on `429` and `503`.
The SDK does all four. The `fetch`, cURL, and Python examples below are deliberately minimal, showing the call rather than a full retry policy; treat them as starting points and add the pacing rules at the end of this page.
## TypeScript SDK [#typescript-sdk]
`@ongoingai/sdk` is a thin typed client. Its types are generated from the same OpenAPI document this site renders, so a contract change is a compile error before it is a runtime surprise.
```bash
npm install @ongoingai/sdk
```
```ts
import { OngoingAI, OngoingAIError } from '@ongoingai/sdk';
const oai = new OngoingAI({
apiKey: process.env.ONGOINGAI_API_KEY!,
appName: 'my-enrichment-job', // sent in User-Agent
});
// 1. Learn what this key can do and how much is left.
const account = await oai.account();
console.log(account.plan.limits.rpm, account.period.used);
// 2. One store.
try {
const { match, store } = await oai.stores.get('gymshark.com');
console.log(match, store.platform?.slug, store.observation.last_completed_scan_at);
} catch (err) {
if (err instanceof OngoingAIError && err.code === 'not_found') {
// Not in the corpus. Not evidence that the site is not a store.
} else throw err;
}
// 3. A batch, with a deterministic idempotency key so a retry never double-bills.
const res = await oai.stores.enrich(['gymshark.com', 'allbirds.com'], {
idempotencyKey: 'nightly-2026-09-10:0',
});
for (const r of res.results) {
if (r.match === 'no_match') continue; // free, unknown to us
console.log(r.input, r.store!.technologies.map((t) => t.slug));
}
// 4. Any number of domains, batched at the plan maximum, resumable by runId.
const allDomains = ['gymshark.com', 'allbirds.com' /* …thousands more */];
for await (const r of oai.stores.enrichAll(allDomains, { runId: 'nightly-2026-09-10' })) {
console.log(r.input, r.match);
}
```
The SDK retries `GET` requests and `POST` requests that carry an idempotency key on `429` (honoring `Retry-After`) up to `maxRetries` times, default 2. It never retries a `POST` without an idempotency key, because a timed-out enrich might have been billed. Errors are `OngoingAIError` with `status`, `code`, `requestId`, and `retryAfterSeconds`.
Source: the `packages/sdk` directory of the OngoingAI repository. The package publishes with the first production release of `/v1`.
## Plain fetch [#plain-fetch]
No dependency needed. This is what the SDK does underneath.
```ts
const BASE = 'https://api.ongoing.ai';
const headers = {
authorization: `Bearer ${process.env.ONGOINGAI_API_KEY}`,
'content-type': 'application/json',
};
async function call(path: string, init: RequestInit = {}, attempt = 0): Promise {
const res = await fetch(BASE + path, { ...init, headers: { ...headers, ...init.headers } });
if (res.status === 429 && attempt < 2 && (init.method ?? 'GET') === 'GET') {
const wait = Number(res.headers.get('retry-after') ?? '1');
await new Promise((r) => setTimeout(r, wait * 1000));
return call(path, init, attempt + 1);
}
const body = await res.json();
if (!res.ok) {
const e = new Error(`${body.error.code}: ${body.error.message}`);
Object.assign(e, { status: res.status, code: body.error.code, requestId: body.error.request_id });
throw e;
}
return body as T;
}
const account = await call('/v1/account');
const store = await call('/v1/stores/gymshark.com');
const batch = await call('/v1/stores/enrich', {
method: 'POST',
headers: { 'idempotency-key': 'nightly-2026-09-10:0' },
body: JSON.stringify({ domains: ['gymshark.com', 'allbirds.com'] }),
});
```
## cURL [#curl]
```bash
export ONGOINGAI_API_KEY="oai_live_…"
export BASE="https://api.ongoing.ai"
# Validate the key
curl -s $BASE/v1/account -H "Authorization: Bearer $ONGOINGAI_API_KEY" | jq .plan.limits
# One store
curl -s $BASE/v1/stores/gymshark.com -H "Authorization: Bearer $ONGOINGAI_API_KEY" | jq '.store | {platform: .platform.slug, seen: .observation.last_completed_scan_at}'
# A batch (idempotent)
curl -s $BASE/v1/stores/enrich \
-H "Authorization: Bearer $ONGOINGAI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: nightly-2026-09-10:0" \
-d '{"domains":["gymshark.com","allbirds.com","notastore.example"]}' | jq '.results[] | {input, match, reason}'
```
Every response carries `X-Request-Id`. Add `-D -` to see it; quote it when something looks wrong.
## Python [#python]
Standard library only.
```python
import json, os, time, urllib.request, urllib.error
BASE = "https://api.ongoing.ai"
KEY = os.environ["ONGOINGAI_API_KEY"]
def call(path, body=None, idempotency_key=None, attempt=0):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method="POST" if data else "GET")
req.add_header("Authorization", f"Bearer {KEY}")
req.add_header("Content-Type", "application/json")
if idempotency_key:
req.add_header("Idempotency-Key", idempotency_key)
try:
with urllib.request.urlopen(req) as res:
return json.load(res)
except urllib.error.HTTPError as e:
payload = json.load(e)
retryable = e.code == 429 and attempt < 2 and (data is None or idempotency_key)
if retryable:
time.sleep(int(e.headers.get("Retry-After", "1")))
return call(path, body, idempotency_key, attempt + 1)
raise RuntimeError(f"{payload['error']['code']}: {payload['error']['message']} (request {payload['error']['request_id']})")
account = call("/v1/account")
print(account["plan"]["limits"])
store = call("/v1/stores/gymshark.com")["store"]
print(store["platform"]["slug"] if store["platform"] else None, store["observation"]["last_completed_scan_at"])
batch = call("/v1/stores/enrich", {"domains": ["gymshark.com", "allbirds.com"]}, idempotency_key="nightly-2026-09-10:0")
for r in batch["results"]:
print(r["input"], r["match"], r.get("reason"))
```
## Pacing a bulk run [#pacing-a-bulk-run]
`GET /v1/account` returns `plan.limits.rpm`, `plan.limits.concurrency`, and `plan.limits.enrich_batch_max`. Respecting all three is what keeps a run inside its limits:
* Batch domains at your plan's `enrich_batch_max`. Read it from the account response rather than assuming 100, which is the contract maximum, not every plan's.
* Keep at most `concurrency` requests in flight. Exceeding it returns `503` with `Retry-After: 1`, which is safe to retry after waiting.
* Space requests so that no minute exceeds `rpm`. Exceeding it returns `429` with `Retry-After` in seconds.
Cost is easier to predict than duration: a run costs one `lookup_cached` per distinct domain that matched, and nothing for the ones that did not. Wall-clock time depends on your own concurrency and on network conditions, so measure it rather than deriving it from the limits.
Before starting, compare `period.included` minus `period.used` against the domain count. When the allowance runs out the API returns `402 payment_required` with `reset_at`; partner keys are not hard-stopped.
## Idempotency keys [#idempotency-keys]
Use one key per logical request and make it deterministic from your own run id and batch index, for example `nightly-2026-09-10:12`. Keys are 8 to 128 characters; anything shorter is rejected as `invalid_request` before the batch runs. A replay within 24 hours returns the original body with `Idempotent-Replayed: true` and bills nothing. The same key with a different body returns `409 conflict`, which is the API telling you a batch boundary moved between attempts.
# Errors and limits (/docs/errors)
## The envelope [#the-envelope]
Every non-2xx response has the same body. Branch on `error.code`; the message wording may change.
```json
{
"error": {
"code": "forbidden_scope",
"message": "This key does not have the read:signals scope.",
"request_id": "req_01J9X4K6ZP3M8Q2R5T7V9W1Y3A"
}
}
```
| Code | Status | Meaning | What to do |
| ------------------ | ------ | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `invalid_request` | 400 | Body or parameters failed validation. The message names the field. | Fix the request. Do not retry unchanged. |
| `unauthorized` | 401 | Missing, malformed, revoked, or expired key. | Check the key; confirm with `GET /v1/account`. |
| `payment_required` | 402 | The period allowance is exhausted. The body carries `reset_at`. | Wait for the reset or raise the plan. |
| `forbidden_scope` | 403 | The key lacks the scope for this operation. | Create a key with the scope; see `key.scopes` on `GET /v1/account`. |
| `not_found` | 404 | No such route, technology slug, or store. For stores: the domain is not in the corpus. | For stores, request an observation with `refresh: true`. Not evidence the site is not a store. |
| `conflict` | 409 | An `Idempotency-Key` was reused with a different body. | Use a new key for the new body. |
| `rate_limited` | 429 | Too many requests for this key. `Retry-After` is in seconds. | Wait `Retry-After`, then retry. |
| `not_implemented` | 501 | The operation is in the contract but not yet served. | Check the reference; the page exists only for served operations. |
| `internal` | 500 | Our fault. | Retry once after a few seconds; quote `request_id` to support if it persists. Not billed. |
## Request ids [#request-ids]
Every response carries an `X-Request-Id` header, and every error body repeats it. You can supply your own with an `X-Request-Id` request header (8 to 64 characters, `A-Z a-z 0-9 _ -`), for example a Clay row id, and it is echoed back. Otherwise the API generates one. Log it with every call; it is how support finds one request among millions.
## Rate and concurrency limits [#rate-and-concurrency-limits]
Limits are per key and come from the plan (`plan.limits` on `GET /v1/account`).
| Header | Meaning |
| ----------------------- | ---------------------------------------- |
| `X-RateLimit-Limit` | Requests per minute for this key |
| `X-RateLimit-Remaining` | Requests left in the current minute |
| `X-RateLimit-Reset` | Seconds until the minute resets |
| `X-Usage-Remaining` | `lookup_cached` units left in the period |
| `Retry-After` | On 429 and 503 only: seconds to wait |
Exceeding the per-minute rate returns `429 rate_limited`. Exceeding concurrency returns `503` with `Retry-After: 1`. Both are safe to retry after waiting.
Request bodies are capped at 256 KB. Enrich batches are capped at `plan.limits.enrich_batch_max` domains.
## Idempotent retries [#idempotent-retries]
Any `POST` accepts an `Idempotency-Key` header. Within 24 hours, the same key with the same body returns the original response, adds `Idempotent-Replayed: true`, and bills nothing. The same key with a different body returns `409 conflict`.
Rules that keep retries safe:
* Send an idempotency key on every `POST` you might retry, including retries after a client-side timeout, which is the case where the first attempt may already have been billed.
* Derive the key from your own stable identifiers (`run-id:batch-index`), not from a timestamp.
* `GET` requests need no key; they are safe to repeat.
## Timeouts [#timeouts]
Synchronous requests are bounded at 10 seconds of database time. `POST /v1/stores/enrich` never fetches a live site inside the request; `refresh: true` queues a scan and returns immediately with `scan_requested: true`. A request that exceeds the bound returns `internal` with a request id and is not billed.
# GraphQL (/docs/graphql)
REST serves the questions integrations ask most: profile this domain, list the vocabulary, tell me what changed. GraphQL serves the cases where the shape of the answer is the point, when one round trip should return a store, its technologies, and their dates together.
Both read the same corpus. Neither is a cache of the other.
## Access [#access]
| | |
| ----------- | --------------------------------------------------------------------------------------------- |
| Endpoint | `POST https://api.ongoing.ai/graphql` |
| Auth | A service token issued directly by OngoingAI |
| Keys | **A workspace API key does not authenticate GraphQL.** `oai_live_…` keys are REST credentials |
| Browser IDE | Not served. The endpoint answers POST with a query, nothing else |
This is the honest state of it: GraphQL is what our own surfaces use, and access is arranged rather than self-served. If your integration needs it, write to `api@ongoing.ai` and say what you are building. Everything published in the [REST reference](/docs/reference) is available to any workspace key today, without asking anyone.
## A query [#a-query]
```graphql
query Store($domain: String!) {
store(domain: $domain) {
domain
rank
firstSeenAt
lastScannedAt
technologies {
active
confidence
firstSeenAt
lastSeenAt
technology { slug name vendorName }
}
}
}
```
```bash
curl https://api.ongoing.ai/graphql \
-H "Authorization: Bearer YOUR_SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"{ store(domain:\"gymshark.com\"){ domain technologies { active technology { slug } } } }"}'
```
Domains are normalized for you, so `GYMSHARK.COM`, `www.gymshark.com`, and a full product URL all resolve to the same store.
## What the schema covers [#what-the-schema-covers]
* **`store(domain)`** — corpus identity, rank, first and last observation, catalog summary, and technology edges with their install dates and confidence.
* **`technology(slug)`** — one entry from the canonical vocabulary. The same slugs REST uses.
* **`candidates(...)`** — the evidence queue: relationships claimed by a source and awaiting verification, cursor paginated.
* **`storeAnalysis(id)`** — the status of an analysis started by `analyzeStore`.
* **Mutations** — ingest relationship evidence from a capture, resolve a candidate to a domain, and queue scans. Evidence ingestion is idempotent on the key you supply, so a retry replays the original receipt rather than writing the capture twice.
## Reading the answers [#reading-the-answers]
Two behaviours are worth knowing before you branch on a response.
**An unknown domain is `null`, not an error.** A `null` store means we have never observed that domain. It is not a statement that the domain is not a store, and treating it as one is the most common way to misread this API.
**Absence of a technology is not removal.** Edges carry `active` along with the dates that support it. A technology that stopped appearing is marked inactive once completed observations of comparable scope confirm it, not the first time a scan misses it. The same rule governs the REST profile, and [Observations](/docs/concepts/observations) explains it in full.
## Which one to use [#which-one-to-use]
| Use | Interface |
| -------------------------------------------------------- | --------- |
| Enrich a list of domains, in a workflow tool or a script | REST |
| One domain, one profile, in a product surface | REST |
| A shape REST does not return in one call | GraphQL |
| Writing evidence back into the graph | GraphQL |
# Overview (/docs)
The OngoingAI API answers three questions about any ecommerce domain: what platform it runs, which technologies are installed, and what changed. Every answer is assembled from completed observations of the live storefront and carries the dates that support it.
| | |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Base URL | `https://api.ongoing.ai` |
| Version | `/v1` in the path. Additive changes are non-breaking; removals and renames go to `/v2`. |
| Auth | `Authorization: Bearer oai_live_…` (workspace API key) |
| Format | JSON in, JSON out. Timestamps are RFC 3339 UTC. |
| Contract | [`/v1/openapi.json`](https://api.ongoing.ai/v1/openapi.json), OpenAPI 3.1 |
| Interfaces | REST here, plus a [GraphQL endpoint](/docs/graphql) used by our own surfaces. A workspace key authenticates REST. |
| Discovery | [`/`](https://api.ongoing.ai/) names the interfaces, [`/v1`](https://api.ongoing.ai/v1) lists the operations a deployment actually serves. Neither needs a key. |
## What is live in this release [#what-is-live-in-this-release]
validates a key and returns plan limits and remaining allowance.
returns one store profile.
returns a batch of profiles, up to your plan's `enrich_batch_max`.
lists the vocabulary those profiles use.
reports consumption this period.
Store history, signal search, watchlists, webhooks, and asynchronous jobs are defined in the contract and arrive in later releases. An operation appears in the [reference](/docs/reference) only once it is served.
## How to read a store profile [#how-to-read-a-store-profile]
A profile is not a claim about the site today. It is a claim about the last completed observation, and the `observation` object tells you when that was and how much it covered. Two rules follow from that:
1. **Absence is not removal.** A technology missing from `technologies` means it was not seen in that observation's scope. Confirmed removals are separate events with their own dates.
2. **Unknown is not negative.** A `404 not_found` or a `no_match` result means the domain is not in the corpus yet. It says nothing about whether the site is a store.
[Observations and evidence](/docs/concepts/observations) covers this in detail. Agents reading these docs programmatically get the same rules in every operation's `x-agent-notes`.
## How these docs stay accurate [#how-these-docs-stay-accurate]
The reference section is generated from the API's own route definitions. Inputs, outputs, examples, and the notes on each operation come from the same source the server validates against, and a release that changes any of them regenerates this site. The [Markdown version](/llms.txt) of every page is built from the same content.
# Quickstart (/docs/quickstart)
### Create an API key [#create-an-api-key]
In the dashboard, open **Settings → API keys** and create a key. Pick a label that names the consumer ("Clay production", "Local testing") and the scopes it needs. For this walkthrough, `read:stores` is enough.
The full key is shown once, at creation. Store it in your secret manager: afterwards the dashboard shows only its prefix, so a key you lose has to be revoked and replaced.
```bash
export ONGOINGAI_API_KEY="oai_live_…"
```
### Confirm the key [#confirm-the-key]
is free and tells you what the key can do.
```bash
curl https://api.ongoing.ai/v1/account \
-H "Authorization: Bearer $ONGOINGAI_API_KEY"
```
Read two things from the response: `key.scopes`, which decides what you may call, and `plan.limits`, which decides how much.
A `401` here means the key is wrong, revoked, or expired. A `200` confirms the key is valid. Later calls can still fail for their own reasons, so check the scopes and limits before a bulk run.
Example account response
The plan and limits below are one example, not what every account receives. Yours are authoritative on your own response.
```json
{
"workspace": { "id": "ws_01J9X4K6ZP3M8Q2R5T7V9W1Y3A", "name": "Acme Agency" },
"key": {
"id": "key_01J9X4K6ZP3M8Q2R5T7V9W1Y3A",
"label": "Local testing",
"prefix": "oai_live_a1b2",
"environment": "live",
"scopes": ["read:stores"],
"expires_at": null
},
"plan": {
"slug": "pro",
"limits": {
"rpm": 120,
"concurrency": 5,
"enrich_batch_max": 100,
"freshness_days": 30,
"history_days": 365,
"live_refreshes_per_period": 100
}
},
"period": {
"starts_at": "2026-09-01T00:00:00Z",
"ends_at": "2026-10-01T00:00:00Z",
"included": {
"lookup_cached": 10000,
"lookup_live": 100,
"history_query": 10000,
"refresh_requested": 0
},
"used": {
"lookup_cached": 0,
"lookup_live": 0,
"history_query": 0,
"refresh_requested": 0
}
}
}
```
### Read a store [#read-a-store]
takes a hostname and canonicalizes it. A full URL works too, but must be percent-encoded, because the domain is a single path segment; the [parameter reference](/docs/reference/stores/get-store) shows that form.
```bash
curl https://api.ongoing.ai/v1/stores/gymshark.com \
-H "Authorization: Bearer $ONGOINGAI_API_KEY"
```
An excerpt of a real response, trimmed to one platform, two technologies, and the observation:
```json
{
"match": "matched",
"store": {
"domain": "gymshark.com",
"platform": { "slug": "shopify", "name": "Shopify", "confidence": "high" },
"status": { "state": "active_storefront", "as_of": "2026-09-07T04:06:05.068Z" },
"technologies": [
{ "slug": "attentive", "name": "Attentive", "category": "sms-marketing", "install_state": "active", "confidence": "high", "last_confirmed_at": "2026-09-01T04:39:18.315Z" },
{ "slug": "cloudflare", "name": "Cloudflare", "category": "cdn", "install_state": "active", "confidence": "high", "last_confirmed_at": "2026-09-01T04:39:18.315Z" }
],
"observation": {
"last_completed_scan_at": "2026-09-07T04:06:05.068Z",
"scope": "homepage_only",
"coverage_note": "Observed on the storefront homepage only. A technology that loads on product, cart, or checkout pages may not appear here."
},
"recent_signals": []
}
}
```
That is the shape of every answer: a store, the technologies we saw, and when we saw them. Three fields deserve a look before you use the rest:
* `observation.last_completed_scan_at` and `observation.scope`: when we last saw the site and how much of it.
* `technologies[].install_state` and `last_confirmed_at`: whether each technology was loading, and when that was last confirmed.
* `recent_signals`: the last five confirmed changes, each with `effective_at` and `confirmed_at`.
This call costs one `lookup_cached` unit. A `404 not_found` costs nothing and means the domain is not in the corpus.
### Enrich a batch [#enrich-a-batch]
answers up to your plan's `enrich_batch_max` domains in one request and preserves input order. 100 is the contract maximum; the free plan allows 25.
```bash
curl https://api.ongoing.ai/v1/stores/enrich \
-H "Authorization: Bearer $ONGOINGAI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: quickstart-batch-1" \
-d '{"domains": ["gymshark.com", "allbirds.com", "notastore.example"]}'
```
Each result has a `match` outcome. `matched` and `stale` results include the store and are billed. `no_match` results carry a `reason` and are free, and inputs that collapse to the same domain are answered individually but billed once.
Generate a **new `Idempotency-Key` for each new batch**, then reuse that key only when retrying that same batch. A retry within 24 hours replays the original response with `Idempotent-Replayed: true` and bills nothing. Reusing a key with a different set of domains returns `409 conflict`, which is the API catching a moved batch boundary rather than answering with the wrong rows.
### Pick a client [#pick-a-client]
The [clients page](/docs/clients) shows the same calls through the TypeScript SDK, plain `fetch`, and Python, with the retry and pacing rules already applied.
## Next [#next]
* [Authentication](/docs/authentication): scopes, test keys, rotation.
* [Errors and limits](/docs/errors): the error envelope, rate limits, idempotency.
* [Observations and evidence](/docs/concepts/observations): how to read absence and freshness.
# Observations and evidence (/docs/concepts/observations)
The API never reports what a site is doing right now. It reports what a completed observation saw, and when. Every store object carries the evidence for its own claims, and the rules below are the ones the API itself follows when it derives a fact.
## Completed observations only [#completed-observations-only]
An observation is a scan that finished with a known scope. A fetch that timed out, was blocked by a bot challenge, returned an error, or was never attempted produces no observation at all. It cannot lower a count, remove a technology, or change a status. This is the single most important property of the data: **incomplete observations are not negative evidence.**
`observation` on each store tells you what you are looking at:
| Field | Meaning |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| `last_completed_scan_at` | When the most recent completed scan finished. `null` means the store is known but never fully observed; treat other fields as unknown. |
| `scope` | `full`, `partial`, or `homepage_only`. What that scan covered. |
| `coverage_note` | Plain-language caveat, for example "product pages returned a bot challenge". |
## Unknown is not "not a store" [#unknown-is-not-not-a-store]
`GET /v1/stores/{domain}` returns `404 not_found`, and enrich returns `match: no_match`, when no store profile was returned. That is not one situation but four, and the `reason` field is what tells them apart. Read it before drawing any conclusion:
| Reason | Meaning |
| ---------------- | -------------------------------------------------------------------------------- |
| `not_in_corpus` | We have never observed this domain. Ask for an observation with `refresh: true`. |
| `invalid_domain` | The input did not canonicalize to a public registrable domain. |
| `not_ecommerce` | We observed it and classified it as not a store. |
| `blocked` | The domain is on a policy denylist. |
Only `not_ecommerce` is a statement about the site: we observed it and classified it. The other three are statements about us, and `not_in_corpus` in particular means missing coverage, not a verdict.
## Absence is not removal [#absence-is-not-removal]
`technologies` lists what the last completed observation saw. A technology missing from the list was not seen in that scope; it was not necessarily removed. Before you conclude that a store dropped a vendor:
1. Check `observation.scope`. A `full` observation supports "not detected within what that scan covered". It does not by itself establish a removal; the confirmation rule below does.
2. Check `last_completed_scan_at`. An old observation says less than a recent one.
3. Use confirmed events. `recent_signals` (and, in a later release, `/v1/stores/{domain}/history`) carries `technology_removed` events, which require the technology to be missing from **three consecutive comparable completed observations** and to be absent from every other page we observe for that store.
## Install states [#install-states]
Each technology carries an `install_state`:
| State | Meaning |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `active` | Loading on the storefront in the last completed observation. |
| `configured` | Referenced but not loading, for example a disabled app block. Counts as installed, not as in use. |
| `inactive` | Seen active before, then missing from three consecutive comparable completed observations, with no other observed page still showing it. This is a confirmed removal. One missed scan never produces it. |
`first_observed_at` and `last_confirmed_at` bracket the evidence. A technology with `last_confirmed_at` equal to `observation.last_completed_scan_at` was seen in the latest scan.
## Confidence [#confidence]
`confidence` is `high`, `medium`, or `low` on platforms, technologies, and signals. `high` means more than one independent signal corroborates the fact. `low` rests on a single weak signal and is a hint to verify, not a fact to act on. Filter on it when precision matters more than recall, which for outreach it usually does.
## Freshness and `stale` [#freshness-and-stale]
Each plan has a freshness window (`plan.limits.freshness_days`). A profile whose `last_completed_scan_at` is older than that window is returned as `match: stale` on enrich. Stale profiles are complete, billed, and usable; the flag exists so you can decide whether to spend a `lookup_live` unit on `refresh: true`.
## Signals [#signals]
A signal is a confirmed change with provenance. The six types:
| Type | Definition |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `store_first_observed` | First completed observation of a storefront. |
| `technology_added` | Present in a completed scan and absent in the latest comparable prior scan. |
| `technology_removed` | Missing from three consecutive comparable completed observations where it was previously present, and not live on any other observed page. `effective_at` is the first of those misses; `confirmed_at` is the scan that met the threshold. |
| `platform_migrated` | Platform changed between two completed scans, confirmed. |
| `store_became_active` | Storefront entered the `active_storefront` state. |
| `store_became_inactive` | Storefront left `active_storefront`, confirmed. |
Each carries `effective_at` (best estimate of when it happened: the first completed observation showing the new state) and `confirmed_at` (when the confirmation rule was satisfied). Signal ids are deterministic: the same fact has the same id on every delivery and replay, so consumers dedupe on `id`.
## Counts state their population [#counts-state-their-population]
Any count the API returns says which population it measures: `candidate`, `reachable`, `verified_ecommerce`, `active_storefront`, or `monitored`. The corpus is much larger than the set of confirmed active storefronts, and the two are not comparable. Public copy quotes only `active_storefront` and `monitored` counts with their date; do the same.
# API reference (/docs/reference)
The pages in this section are generated from the OpenAPI document the API serves at `/v1/openapi.json`. Parameters, schemas, examples, and the agent notes on each operation come from the route definitions the server validates against, and CI fails when the committed document and those definitions disagree. That is the mechanism that keeps this reference honest; if you ever find it describing something the API does not do, report it as a bug.
| Group | Operations |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| Account | [Validate the key](/docs/reference/account/get-account), [Usage this period](/docs/reference/account/get-usage) |
| Stores | [Store profile by domain](/docs/reference/stores/get-store), [Enrich a batch of domains](/docs/reference/stores/enrich-stores) |
| Technologies | [Technology vocabulary](/docs/reference/technologies/list-technologies), [One technology](/docs/reference/technologies/get-technology) |
| Service | [Service health](/docs/reference/service/get-health) |
Each operation page states its scope (`x-scope`), the billing unit it consumes (`x-billing-unit`), and notes for agents (`x-agent-notes`). The [data dictionary](/docs/concepts/observations) explains the fields that need care: observation scope, install states, confidence, and match outcomes.
Download the contract: [`v1.json`](https://api.ongoing.ai/v1/openapi.json).
# Service health (/docs/reference/service/get-health)
## GET https://api.ongoing.ai/v1/health
**Service health**
Liveness and build identity. Unauthenticated and unmetered.
scope: `none` · billing unit: `free` · operationId: `getHealth`
> Agent notes: Call this to confirm connectivity before diagnosing an authentication problem. It never requires a key.
Responses:
- 200 `Health`: Service is up.
# What this host serves (/docs/reference/service/get-index)
## GET https://api.ongoing.ai/
**What this host serves**
Discovery document for the host: the REST base url, the GraphQL endpoint, where the OpenAPI document lives, and where to get a key. Unauthenticated, unmetered, and static — it reads nothing and says nothing about system health.
scope: `none` · billing unit: `free` · operationId: `getIndex`
> Agent notes: Start here when you know only the hostname. Follow `rest.openapi_url` for the machine-readable description of every operation rather than guessing paths. A 200 here means the host is reachable, not that any dependency is healthy; use getHealth for that.
Responses:
- 200 `ApiIndex`: The service index.
# Operations served at v1 (/docs/reference/service/get-version-index)
## GET https://api.ongoing.ai/v1
**Operations served at v1**
The operations this build serves, as operation id to URL. Unauthenticated and unmetered. An operation defined in the contract but not yet implemented is omitted here.
scope: `none` · billing unit: `free` · operationId: `getVersionIndex`
> Agent notes: Use this to check what a deployment actually serves before calling it: the contract describes the whole surface, this lists what is live. Resolve `{}` placeholders yourself; they are path parameters.
Responses:
- 200 `VersionIndex`: The version index.
# Validate the key and discover capabilities (/docs/reference/account/get-account)
## GET https://api.ongoing.ai/v1/account
**Validate the key and discover capabilities**
Returns the workspace, the key used for this request, the plan limits in force, and the current billing period. Make this the first call of any integration: it tells you which scopes you hold and how much allowance remains before you spend any of it.
scope: `any` · billing unit: `free` · operationId: `getAccount`
> Agent notes: Free and safe to call repeatedly. Use `plan.limits.rpm` and `concurrency` to pace requests and `period.used` versus `period.included` to decide whether a bulk run fits the remaining allowance. A 401 here means the key is wrong or revoked, not that the service is down.
Responses:
- 200 `Account`: The key is valid.
- 401 `ErrorEnvelope`: Missing, malformed, revoked, or expired key.
# Usage this period (/docs/reference/account/get-usage)
## GET https://api.ongoing.ai/v1/usage
**Usage this period**
Consumption by billing unit for the current period, in total and per key. Updated within five minutes of each request.
scope: `any` · billing unit: `free` · operationId: `getUsage`
> Agent notes: Free. Read `period.included` minus `period.used` per unit to size a batch. Per-key rows include revoked keys that spent units this period so the totals reconcile.
Responses:
- 200 `Usage`: Usage for the workspace.
- 401 `ErrorEnvelope`: Missing or invalid key.
# Store profile by domain (/docs/reference/stores/get-store)
## GET https://api.ongoing.ai/v1/stores/{domain}
**Store profile by domain**
The current profile of one store from completed observations: platform, technologies with install state and dates, geography, industry, catalog aggregates, lifecycle status, and the last five confirmed signals. Cacheable; identical inputs return identical bodies until the next completed observation.
scope: `read:stores` · billing unit: `lookup_cached` · operationId: `getStore`
> Agent notes: One lookup_cached unit on 200; nothing on 404. A 404 with code not_found means the domain is not in the corpus, which is not evidence that the site is not a store; use POST /v1/stores/enrich with refresh: true (if the key has write:scans) to request an observation. Before concluding a technology is absent, check observation.scope: only a `full` scope supports that reading. Never infer an uninstall from a missing entry when last_completed_scan_at is old; use /history for confirmed removals.
Parameters:
- `domain` (path, required): Hostname of the store, for example `gymshark.com`. A full URL also works but must be percent-encoded, because this is a single path segment. Either way the value is canonicalized server-side to the registrable domain; the response carries the canonical `domain` and, when different, the `storefront_host` that actually serves the shop.
- `x-request-id` (header): Optional caller-supplied request id (8 to 64 characters, `A-Z a-z 0-9 _ -`). Echoed back on the response; otherwise the API generates one.
Responses:
- 200 `StoreResponse`: The store.
- 401 `ErrorEnvelope`: Missing or invalid key.
- 403 `ErrorEnvelope`: Key lacks `read:stores`.
- 404 `ErrorEnvelope`: Domain is not in the corpus or did not canonicalize. Not evidence that the site is not a store.
- 429 `ErrorEnvelope`: Rate limit exceeded; honor `Retry-After`.
# Enrich up to 100 domains (/docs/reference/stores/enrich-stores)
## POST https://api.ongoing.ai/v1/stores/enrich
**Enrich up to 100 domains**
Batch form of the store profile. Each input is canonicalized and answered independently; the response preserves input order. Matched and stale inputs return the full store object. No-match inputs return a reason and are not billed. With `refresh: true` and the `write:scans` scope, unknown or stale domains are queued for observation and the result is marked `scan_requested`.
scope: `read:stores` · billing unit: `lookup_cached` · operationId: `enrichStores`
> Agent notes: Bills one lookup_cached per matched or stale result and one lookup_live per queued scan; no_match and error results are free. Send an Idempotency-Key on every retry so a timeout cannot double-bill. Keep batches at or under plan.limits.enrich_batch_max. Results with match: no_match and reason: not_in_corpus are unknown to us, not confirmed non-stores. A scan_requested result completes later; poll GET /v1/stores/{domain} after a few minutes or subscribe to events.
Parameters:
- `x-request-id` (header): Optional caller-supplied request id (8 to 64 characters, `A-Z a-z 0-9 _ -`). Echoed back on the response; otherwise the API generates one.
- `idempotency-key` (header): Optional. Replaying the same key with the same body within 24 hours returns the original response with `Idempotent-Replayed: true` and bills nothing. Same key with a different body returns `409 conflict`.
Request body: `EnrichRequest` (see the data dictionary).
Responses:
- 200 `EnrichResponse`: One result per input.
- 400 `ErrorEnvelope`: Malformed body or more than the batch maximum.
- 401 `ErrorEnvelope`: Missing or invalid key.
- 403 `ErrorEnvelope`: Key lacks `read:stores`, or `refresh` without `write:scans`.
- 409 `ErrorEnvelope`: Idempotency key reused with a different body.
- 429 `ErrorEnvelope`: Rate limit exceeded; honor `Retry-After`.
# Technology vocabulary (/docs/reference/technologies/list-technologies)
## GET https://api.ongoing.ai/v1/technologies
**Technology vocabulary**
The canonical list of platforms, apps, services, and themes the API can report, with categories. Slugs here are the same slugs used in store profiles, signals, webhooks, and MCP tools.
scope: `read:stores` · billing unit: `free` · operationId: `listTechnologies`
> Agent notes: Free. Resolve a user-supplied name to a slug here before filtering signals or comparing stores; never guess a slug. The list is paginated; follow next_cursor until null for a complete vocabulary, or use q for a prefix search.
Parameters:
- `kind` (query): Filter to one kind.
- `category` (query): Filter to one category slug.
- `q` (query): Case-insensitive prefix match on `name` or `slug`.
- `cursor` (query): Cursor from a previous page.
- `limit` (query): Page size, 1 to 200.
Responses:
- 200 `TechnologyPage`: A page of technologies.
- 401 `ErrorEnvelope`: Missing or invalid key.
# One technology (/docs/reference/technologies/get-technology)
## GET https://api.ongoing.ai/v1/technologies/{slug}
**One technology**
Metadata for one slug.
scope: `read:stores` · billing unit: `free` · operationId: `getTechnology`
> Agent notes: Free. A 404 means the slug is not in the vocabulary; check spelling with listTechnologies and q.
Parameters:
- `slug` (path, required): Technology slug from the vocabulary.
Responses:
- 200 `Technology`: The technology.
- 401 `ErrorEnvelope`: Missing or invalid key.
- 404 `ErrorEnvelope`: Unknown slug.