ThemysThemys/Docs

Batch Endpoint

Analyze up to 5 Terms of Service items in a single API call.

POST /api/analyze/batch

Send up to 5 Terms of Service URLs or text blobs in one HTTP request. Each item is analyzed independently; partial failures are returned alongside successes.

Required scope

API key: analyze

Limits

  • Maximum items per request: 5
  • Minimum items per request: 1
  • Per-item accounting: each valid item reserves quota independently. Items that cannot reserve quota fail without preventing other items from completing.
  • Latency target: ~1.5s per item at p50 on a Base-tier key. Worst-case 5-item batch tolerates up to 8s before Cloudflare Worker timeout.

Request

bash
curl -X POST https://themys.ca/api/analyze/batch \
  -H "Authorization: Bearer $THEMYS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {"url": "https://stripe.com/terms"},
      {"url": "https://vercel.com/legal/terms"},
      {"text": "By signing up you agree to..."}
    ]
  }'

Each item may have text or url (one required, both allowed). The optional domain field normalizes the domain display when scanning by text:

json
{
  "items": [
    {
      "url": "https://stripe.com/terms",
      "domain": "stripe.com"
    },
    {
      "text": "By signing up you agree to share your email with our advertising partners.",
      "domain": "example.com"
    }
  ]
}

Without domain, items with text show as "(unknown)" on the result card. Recommend always providing domain for text-only items.


Response

200 OK on success (even with partial per-item failures). Items run independently:

json
{
  "results": [
    {
      "ok": true,
      "index": 0,
      "cached": false,
      "result": {
        "riskScore": 6.8,
        "riskLevel": "yellow",
        "summary": "Detected 5 flagged clauses…",
        "flags": [/* ... */],
        "tosdr": { "matched": true, "rating": "B" }
      }
    },
    {
      "ok": true,
      "index": 1,
      "cached": true,
      "result": { "...": "..." }
    },
    {
      "ok": false,
      "index": 2,
      "error": "Empty text from https://blocked.com/terms",
      "status": 502
    }
  ],
  "summary": {
    "total": 3,
    "succeeded": 2,
    "failed": 1,
    "authenticatedAs": "api-key"
  },
  "usage": {
    "tier": "base",
    "scans": {
      "allowed": true,
      "remaining": 23,
      "limit": 25,
      "resetsAt": 1784174400000,
      "windowEnd": 1784174400000
    },
    "ai": {
      "allowed": true,
      "remaining": 28,
      "limit": 30,
      "resetsAt": 1785556800000,
      "windowEnd": 1785556800000
    },
    "creditBalance": 0
  }
}

Top-level shape:

FieldTypeDescription
resultsArrayPer-item result, in input order. Each entry has ok, index, and either result or error.
summary.totalnumberNumber of items in the request.
summary.succeedednumberCount of items returning ok: true.
summary.failednumberCount of items returning ok: false.
summary.authenticatedAs"api-key" \"clerk"Which auth surface was used.
usage.scansobjectUpdated daily scan-allowance snapshot for the calling identity.
usage.aiobjectUpdated monthly AI-allowance snapshot for the calling identity.
usage.creditBalancenumberRemaining purchased credits for Clerk callers; API-key calls report 0.

Per-item shape

Success:

json
{
  "ok": true,
  "index": 0,
  "cached": false,
  "result": { /* full AnalysisResult — same shape as POST /api/analyze */ }
}

Failure:

json
{
  "ok": false,
  "index": 1,
  "error": "Empty text from https://blocked.com/terms",
  "status": 502
}

status mirrors the HTTP status the equivalent single-shot call would have returned (e.g. 502 for URL extraction failure, 400 for missing input).

Status codes

CodeMeaning
200Batch processed (some items may have ok: false)
400Empty / oversized batch (0 items or more than 5)
401No auth (Clerk session or API key required)
403API key missing the analyze scope
413Request body exceeds 1 MiB
500Internal server error

Note: a 200 OK with summary.failed > 0 is success — it means the batch envelope was processed but individual items had errors. Inspect each results[i] to see per-item failure causes.


Cost and rate limits

  • Each successful item charges one slot against your per-key daily scan budget (or the browser-user pool for Clerk callers).
  • Failed items don't consume budget.
  • Quota exhaustion appears as an item-level 429 for API-key callers or 402 for Clerk callers without available overflow credits.
  • After the batch, usage.scans.remaining and usage.ai.remaining reflect the post-charge state.
  • Send an Idempotency-Key header when retrying a request. A completed replay is reported again without consuming the same allowance or credit twice; a failed, refunded attempt may retry provider work and is charged only if that retry succeeds.
Provider cost: ~$0.003 in LLM spend per non-cached item (cache hits incur no new provider spend). A 5-item batch with 1 cache hit costs ~$0.012 in provider spend — the same as 4 single-shot calls. Every successful item still consumes one scan allowance; only uncached items consume AI allowance.

Code examples

Python

python
import os
import requests

API_KEY = os.environ["THEMYS_API_KEY"]
items = [
    {"url": "https://stripe.com/legal"},
    {"url": "https://vercel.com/legal/terms"},
    {"url": "https://github.com/site/terms"},
]

response = requests.post(
    "https://themys.ca/api/analyze/batch",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={"items": items},
    timeout=30,
)
response.raise_for_status()
data = response.json()

for item in data["results"]:
    if item["ok"]:
        print(f"  {item['index']}: ranking signal {item['result']['riskScore']}")
    else:
        print(f"  {item['index']}: FAILED - {item['error']}")

print(f"\n{data['summary']['succeeded']}/{data['summary']['total']} succeeded")
print(f"Remaining scans: {data['usage']['scans']['remaining']}/{data['usage']['scans']['limit']}")

JavaScript

javascript
const THEMEYS_API_KEY = process.env.THEMYS_API_KEY;

async function analyzeBatch(items) {
  const r = await fetch("https://themys.ca/api/analyze/batch", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${THEMYS_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ items }),
  });

  if (!r.ok) {
    const err = await r.json();
    throw new Error(`${r.status}: ${err.error}`);
  }

  return r.json();
}

const result = await analyzeBatch([
  { url: "https://stripe.com/legal" },
  { url: "https://vercel.com/legal/terms" },
]);

for (const item of result.results) {
  if (item.ok) {
    console.log(`Item ${item.index}: Risk ${item.result.riskScore}`);
  } else {
    console.error(`Item ${item.index}: ${item.error}`);
  }
}
console.log(`${result.summary.succeeded}/${result.summary.total} succeeded`);


When to use vs single-shot

Use /api/analyzeUse /api/analyze/batch
One-off scan from a scriptBulk vendor onboarding workflows
Web app / extensionCompliance audits across many ToS
Webhook handler with one URLWatchdog that polls many URLs
Async background job that can't tolerate parallel calls

Anything hammering the API

Both endpoints return the same AnalysisResult shape per item. The batch endpoint simply wraps multiple calls with shared rate-limit accounting.

Was this page helpful?