Connect with a client

The TypeScript SDK, plain fetch, cURL, and Python, with retries, idempotency, and pacing done right.

A code window with a plug

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

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

npm install @ongoingai/sdk
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

No dependency needed. This is what the SDK does underneath.

const BASE = 'https://api.ongoing.ai';
const headers = {
  authorization: `Bearer ${process.env.ONGOINGAI_API_KEY}`,
  'content-type': 'application/json',
};

async function call<T>(path: string, init: RequestInit = {}, attempt = 0): Promise<T> {
  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

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

Standard library only.

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

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

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.

On this page