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']}: {item['result']['riskScore']}/10")

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?