All pages

POST /api/v1/batch

Batch

Many things in one call, answered now or queued for later. What a backfill uses.

Many things in one call. The same verdicts the single endpoints give, reached the same way and billed the same way. Batching changes the number of round trips and nothing else.

One request per comment is right for a form and wrong for a backfill: two million old posts would be two million handshakes, key lookups and round trips, and you would be rate limited into next week for doing exactly what this service is for.

Request

curl https://toxicfilter.com/api/v1/batch \
  -H "Authorization: Bearer tf_live_..." \
  -H "Idempotency-Key: backfill-2026-08-page-41" \
  -H "Content-Type: application/json" \
  -d '{
    "locales": ["es"],
    "surface": "comment",
    "ai": false,
    "items": [
      { "kind": "text",   "content": "un comentario cualquiera", "reference": "c_991" },
      { "kind": "email",  "address": "x@mailinator.com",         "reference": "u_12"  },
      { "kind": "image",  "url": "https://cdn.example.com/a.jpg","reference": "p_77"  },
      { "kind": "name",   "name": "asdkjhasd",                   "reference": "u_13"  },
      { "kind": "signup", "name": "Ana", "email": "ana@example.com", "bio": "..." },
      { "kind": "url",    "url": "https://bit.ly/3xYz",              "reference": "l_9"   },
      { "kind": "image",  "data": "iVBORw0KGgo...",                  "reference": "p_78"  },
      { "kind": "conversation", "messages": [ { "author": "u1", "content": "..." } ] }
    ]
  }'

The envelope

FieldTypeRequired
itemsobject[]yes1 to 100 (1,000 with async). Each one is the body a single endpoint would take, plus kind.
asyncbooleannoDefault false. See below.
aibooleannoA default, applied to every item that does not set its own. Items no model can read ignore it, so a mixed list of comments and addresses is fine; only an item asking for the model on one of those is an error.
localesstring[]noSame. A thousand comments from one forum should not each repeat it.
surfacestringnoSame.
policystringnoSame, and an item can name its own. A policy the account does not have fails that item, or the whole call when the envelope names it.
projectstringnoThe project the whole batch is filed under. On the envelope only: an item that sends its own is refused with unknown_field.
rulesobjectnoRules for this call, and an item can carry its own instead. Validated here exactly as on a single call, so a block line of "loose" is refused rather than read as zero.
actorstringnoSame, and an item can name its own.
redactbooleannoSame, and an item can set its own.

kind is text, email, name, signup, image, url or conversation, and the rest of the item is exactly what that endpoint documents: content for text, address for email, url for an image. Item fields win over envelope defaults. This is the one endpoint with a type field on the payload, and it has to be: a hundred things of five kinds cannot be five requests.

Response

{
  "batch_id": "bat_01jr7q9x2c8h4m6v0b3n5k7t9d",
  "status": "completed",
  "async": false,
  "count": 5,
  "processed": 4,
  "failed": 1,
  "summary": { "allow": 3, "review": 1, "block": 0, "failed": 1 },
  "credits_charged": 4,
  "errors": [ { "index": 3, "error": { "code": "validation_failed", "...": "..." } } ],
  "results": [
    { "index": 0, "id": "mod_01jr7q...", "reference": "c_991", "decision": "allow",
      "flagged": [], "scores": {}, "signals": [], "used_ai": false, "took_ms": 2,
      "cached": false },
    { "index": 3, "reference": "u_13",
      "error": { "code": "validation_failed", "message": "This item was not judged.",
                 "fields": { "name": ["The name field is required."] } } }
  ],
  "credits": { "remaining": 4767, "charged": 4, "renews_at": "2026-09-30T00:00:00+00:00" }
}

Results come back in the order you sent them, each carrying its index and its reference. Match on either; nothing has to be matched by position alone, which is what breaks the moment one item is rejected.

One bad item is an item, not a batch

A malformed item produces an error in its place and a 200 overall. Ninety-nine verdicts that were already reached, and already paid for, are not thrown away because the hundredth was missing a field.

Per-item code
validation_failedThe item's own fields. fields says which.
unknown_kindkind missing or not one of the seven.
image_too_largeAn inline picture over the size limit. Send a smaller one, or a URL.
nothing_to_checkA signup with none of name, email or bio.
ai_unavailableThe item itself set "ai": true on a name, an email or a url, which no model reads. The envelope's ai is a default and never fails an item, or a mixed batch of comments and addresses could not be sent at all.
unknown_policyThe item named a policy this account does not have. Named on the envelope instead, it fails the whole call.
unknown_ruleA name inside the item's rules that is not a category, subject, lead type or term list. On the envelope it fails the whole call, because every item would then be judged under a line that acts on nothing.
internal_errorSomething failed on our side. Nothing was charged for it.

Only the envelope can fail the whole call: no items, or more than the cap allows, is a 422 and nothing is judged.

Its own rate limit

A batch counts against 60 requests a minute (10 on a test key), in a bucket of its own rather than the 600 the single endpoints get. A backfill and a signup form are the same customer and the same key, and sharing a bucket means one takes the other down, which is exactly what you do not want from the endpoint built for backfills.

What it costs

Per item, at the usual prices, and only for items that were actually judged. Refused items cost nothing.

The quota is checked over the whole batch before any of it runs, which is the only check that means anything here: charging never refuses, so two hundred image calls against an account with forty credits would otherwise go through and land some two thousand credits in the red. Each item is estimated the way a single call is, and if the total does not fit you get a 402 naming it, and nothing is judged. What is charged afterwards is what each item really used.

An async batch sets its estimate aside until it finishes, so a second one has to fit beside the first: the 402 says how much is held that way in credits.queued. If the credits run out while a batch is being worked through, the items left are not judged and not charged; each comes back as an error with the code quota_exhausted.

Repeated content inside one batch is judged once and the rest are answered from cache at one credit each, which matters, because a backfill is the same spam over and over.

async: true

Same endpoint, same body. You get a 202 immediately and the work happens on our workers.

{
  "batch_id": "bat_01jr7q...",
  "status": "queued",
  "async": true,
  "count": 840,
  "processed": 0,
  "failed": 2,
  "status_url": "https://toxicfilter.com/api/v1/batches/bat_01jr7q...",
  "credits": { "remaining": 4767, "charged": 0, "renews_at": "..." }
}

Items are validated at submit time, so malformed ones are reported in that first answer rather than ten minutes later. credits.charged is zero because async items are billed as they are judged: the one thing a 202 cannot tell you is what the work will cost.

GET /api/v1/batches/{id}

curl "https://toxicfilter.com/api/v1/batches/bat_01jr7q...?limit=100&after=99" \
  -H "Authorization: Bearer tf_live_..."

Free, outside the quota, and on the same generous limiter as /usage: polling the progress of a backfill you have already paid for should not spend rate limit or credits. status goes queued → running → completed. GET /api/v1/batches lists the recent ones.

Results page with a cursor: after is the last index you have seen, and next_after comes back non-null while there is more. A cursor and not a page number, because rows appear as workers finish them and an offset into a growing list skips whatever was inserted behind it.

Results read back later have no evidence. Signals carry the fragments that triggered them, fragments are content, and content is never stored. A verdict fetched from a batch keeps its reason, the sentence you can show a person, but not the quote. If you need the quotes, use a sync batch and keep what comes back.

Retrying a batch

Send an Idempotency-Key. A batch is the worst possible thing to submit twice by accident, and a timeout on a hundred-item call is exactly when you cannot tell whether it landed.