Automated Content Safety · Text
Decide what to publish, review, or reject — in one call.
ACS Lite is the lighter, faster, text-only edition of Automated Content Safety. Every verdict names the policy rules that produced it, carries a calibrated probability and severity, and is pinned to the ruleset version bound to your credential.
Introduction
ACS Lite is the text-only edition of Automated Content Safety: a lighter, faster service that decides whether a piece of user-generated text should be published, routed to a human reviewer, or rejected. Each credential is bound to a versioned ruleset. For every rule the service returns a calibrated violation probability, and thresholds convert those probabilities into one of three actions.
Publish immediately. No rule reached its review threshold.
Route to a moderator. A rule is suspicious but below the block line.
Reject. A rule crossed its block threshold.
Design principles
- One response shape. Single and batch calls return the same result object, so client code branches once.
- Explainable outcomes. Every verdict names the rules that fired, with probabilities and a severity band.
- Deterministic and versioned. The same input under the same ruleset version always yields the same verdict, and the ruleset travels with the response.
- Policy without code changes. Rulesets, thresholds and rule text are configuration; your integration does not change when policy does.
Authentication
Every request carries a bearer credential issued to your integration:
Authorization: Bearer acs_live_XXXXXXXXXXXXXXXXXXXXXXXX
- Credentials are displayed once at issue time and stored only as a digest. If one is lost it must be replaced.
- They are server-side only. Never embed one in a browser, mobile binary or public repository.
- Two scopes exist:
checkfor moderation calls andreadfor/rulesand/me. Requesting an out-of-scope endpoint returns403 insufficient_scope. - Each credential is bound to one ruleset and may carry a spend budget. Both are managed by your integration owner.
- Use a separate credential per workload and environment, so ceilings, scopes, rulesets, budgets and revocation remain independent.
- Revocation and suspension take effect globally within 60 seconds.
Verify a credential
curl https://acs-lite.api.efficientstack.com/api/v1/me -H "Authorization: Bearer $ACS_API_KEY"
A missing header returns 401 missing_api_key; an unknown or deleted credential
returns 401 invalid_api_key. Neither response distinguishes between the two failure causes beyond the code.
Quickstart
Export the credential supplied by your integration owner and make one call.
curl https://acs-lite.api.efficientstack.com/api/v1/check \
-H "Authorization: Bearer $ACS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"content": "anyone know her real name? i think she works at the cafe on 5th"}'
A blocking verdict looks like this:
{
"object": "moderation",
"id": "req_8ZQ1nT4pKcVm",
"decision": "block",
"flagged": true,
"score": 4,
"confidence": 0.91,
"rules": [
{ "id": "TX-A1", "name": "Doxxing / private information",
"probability": 0.91, "score": 4, "action": "block" }
],
"reason": "tx-a1 doxxing / private information (p=0.91)",
"cached": false,
"ruleset": { "id": "default", "label": "comments-v1", "version": 7 },
"model": "acs-decision-1",
"latency_ms": 148
}
Three fields carry the decision: decision is what to do, rules is
why, and score (0–5) is how severe. Everything else is metadata you should persist for audit.
- Set a client timeout on the inline path (2–3 seconds is typical) and a degradation policy for
5xxand402. - Persist
decision,rules,confidence,ruleset.idandruleset.versionwith each moderated item. - Log
X-Request-Idfor every non-2xx response; support requests are resolved by that identifier. - Use
/batchfor anything asynchronous and/checkfor request-time decisions.
Core concepts
Ruleset
A named, versioned group of rules. Every credential is bound to exactly one ruleset, which decides the rules it is
evaluated against. Rulesets can share rules — a common baseline — or hold rules and thresholds dedicated to a single
integration. GET /api/v1/rules returns your ruleset; its identifier and version also appear in the
X-Ruleset and X-Ruleset-Version headers of every verdict.
Rule
A stable identifier, a human name, criteria describing what is prohibited and what is explicitly permitted, and thresholds. A ruleset may override a rule's thresholds for its own clients without affecting anyone else.
Probability
For each evaluated rule the service produces a probability in $[0, 1]$ that the content violates that rule, judged only against that rule's criteria. Probabilities are independent: content can score high on one rule and zero on all others.
Thresholds
Two thresholds per rule convert a probability into an action. The block threshold is mandatory; the review threshold is optional and must sit below it.
probability >= block_threshold -> block
probability >= review_threshold -> review
otherwise -> allow
Severity
score maps the winning probability onto a 1–5 band (default cut points 0.55 / 0.70 / 0.85 / 0.95), so
escalation logic can distinguish a marginal hit from an unambiguous one without hard-coding probabilities. An
allow verdict has score: 0.
Confidence
For a flagged item, confidence is the probability of the strongest rule. For an allow, it is
$1 - p_{max}$ — that is, how clean the item looks. It is always a number in $[0, 1]$.
Budget
A credential may carry a monthly and/or daily spend budget. A hard budget rejects calls with
402 budget_exceeded once reached; a soft budget keeps serving and reports X-Budget-Status: exceeded.
POST /check
Single evaluation
Evaluate one item. This is the endpoint for inline, request-time checks. Passing an array in content is
accepted as a convenience and returns a batch-shaped response.
Request body
Response
Narrowing the evaluation
Pin a rule subset for narrow surfaces — for example only spam rules on a search box — to reduce latency:
{
"content": "…",
"rules": ["TX-E1", "TX-H1"],
"include": ["rules", "reason", "probabilities"]
}
POST /batch
Bulk evaluation
One request, up to 200 items, evaluated concurrently. Use it for backfills, imports, re-scans after a policy change and any queue-driven pipeline.
curl https://acs-lite.api.efficientstack.com/api/v1/batch \
-H "Authorization: Bearer $ACS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "id": "c_1001", "content": "this is excellent work" },
{ "id": "c_1002", "content": "kys, nobody wants you here" },
{ "id": "c_1003", "content": "free premium at sketchy-site.example" }
],
"include": ["rules", "reason"]
}'
Results are returned in submission order and also carry index:
{
"object": "moderation.batch",
"id": "req_ba7Kd2Qm",
"results": [
{ "index": 0, "id": "c_1001", "decision": "allow", "flagged": false,
"score": 0, "confidence": 0.98, "rules": [], "reason": "", "cached": true },
{ "index": 1, "id": "c_1002", "decision": "block", "flagged": true,
"score": 4, "confidence": 0.88,
"rules": [{ "id": "TX-G1", "name": "Self-harm and suicide",
"probability": 0.88, "score": 4, "action": "block" }],
"reason": "tx-g1 self-harm and suicide (p=0.88)", "cached": false },
{ "index": 2, "id": "c_1003", "decision": "review", "flagged": true,
"score": 2, "confidence": 0.31,
"rules": [{ "id": "TX-E1", "name": "Spam, scams and off-platform solicitation",
"probability": 0.31, "score": 2, "action": "review" }],
"reason": "needs review: tx-e1 spam, scams and off-platform solicitation (p=0.31)",
"cached": false }
],
"summary": { "items": 3, "blocked": 1, "review": 1, "allowed": 1, "errors": 0, "cached": 1 },
"ruleset": { "id": "default", "label": "comments-v1", "version": 7 },
"model": "acs-decision-1",
"latency_ms": 212
}
Request body
Partial failures
A batch returns 200 whenever the request itself was valid. An item that could not be evaluated carries
"decision": "error" and an error object; the remainder of the batch is unaffected. Always
inspect summary.errors and retry only the failed indices.
const failed = res.results.filter((r) => r.decision === "error");
if (failed.length) await retry(failed.map((r) => items[r.index]));
200 is the ceiling, not the target. Batches of 25–50 give the best latency per item; larger batches are more efficient per request but take longer to return. For throughput, run several medium batches in parallel rather than one maximal batch.
GET /rules
Policy introspection · scope read
Returns the ruleset bound to your credential, with its effective thresholds and version. Cache the payload in your
application and refresh when version changes; the version is also returned in the
X-Ruleset-Version header on every verdict.
{
"object": "list",
"ruleset": { "id": "acme-comments", "name": "Acme · comments", "label": "acme-comments-v1",
"version": 7, "updated_at": "2026-09-18T09:12:44.118Z" },
"defaults": { "block_threshold": 0.35, "review_threshold": 0.18, "score_bands": [0.55, 0.7, 0.85, 0.95] },
"data": [
{ "id": "TX-A1", "name": "Doxxing / private information",
"description": "Prohibited: … Allowed: …",
"thresholds": { "block": 0.3, "review": 0.15 } }
]
}
Use this endpoint to render rule names in reviewer interfaces, to validate a
rules subset before sending it, and to detect policy changes that warrant re-scoring historical decisions.
Thresholds already include any override your ruleset applies.
GET /me
Credential introspection · scope read
Confirms that a credential works and reports its ruleset, entitlements, ceilings, budget and rolling 24-hour usage.
budget is null when no budget is configured.
{
"object": "api_key",
"id": "key_7Rk2pQ",
"name": "comments-service (production)",
"status": "active",
"scopes": ["check", "read"],
"ruleset": { "id": "acme-comments", "name": "Acme · comments", "label": "acme-comments-v1", "version": 7 },
"rate_limit": { "requests_per_second": 50, "scope": "per location, approximate" },
"limits": { "max_batch_items": 200, "max_content_chars": 8000 },
"usage_24h": { "requests": 184220, "items": 962104, "blocked": 7311, "review": 15402, "input_tokens": 41822190 },
"budget": {
"status": "ok", "mode": "hard", "enforced": true,
"monthly_usd": 500, "daily_usd": null,
"spent_month_usd": 212.4182, "spent_today_usd": 9.3071,
"remaining_month_usd": 287.5818, "remaining_today_usd": null,
"alert_pct": 80,
"month_resets_at": "2026-10-01T00:00:00.000Z", "day_resets_at": "2026-09-27T00:00:00.000Z"
},
"created_at": "2026-04-02T11:20:06.441Z"
}
Usage and spend figures are approximate and refreshed periodically; treat them as operational telemetry rather than billing records.
GET /health
Liveness · no authentication
A dependency-free liveness probe suitable for synthetic monitoring. It does not evaluate content and does not consume rate-limit budget.
curl https://acs-lite.api.efficientstack.com/api/v1/health
{ "object": "health", "status": "ok", "version": "1.0.0", "time": "2026-09-26T08:14:02.118Z" }
A 200 from this endpoint means the API surface is reachable. It does not assert
that the evaluation path is healthy — use a low-volume /check canary for that.
Tuning thresholds
The strongest hit wins: if any rule blocks, the verdict is block; if none blocks but one reaches its
review threshold, the verdict is review.
Precedence
request override -> ruleset override -> rule threshold -> platform default
Trialling a change
Thresholds live in your ruleset. Override them per request while you experiment, then ask for the winning values to be published to your ruleset:
{
"content": "…",
"thresholds": { "TX-E1": { "block": 0.55, "review": 0.25 } }
}
How to move them
- Lower the block threshold to catch more, at the cost of a higher false-positive rate.
- Raise it to block less, at the cost of a higher miss rate.
- Add or widen the review tier when neither trade is acceptable. It converts silent misses into queued items rather than wrongful blocks, and is usually the cheaper intervention.
Measuring
Score a labelled sample with include: ["probabilities"] and evaluate candidate thresholds offline against
the returned probabilities. Because the same input yields the same probabilities for a given ruleset version, one scoring
pass supports any number of threshold candidates.
{ "items": [ … ], "include": ["probabilities"], "cache": false }
Caching and idempotency
Verdicts are reused per content + ruleset version + threshold override + context. Repetitive traffic — and
comment traffic is highly repetitive — is served from the verdict cache without re-evaluating the model, and cached
verdicts do not count towards your budget. Cached responses are identical in shape and set "cached": true
together with an X-Cache header of hit, partial or miss.
- Identical items within one batch are evaluated once.
- Publishing a ruleset change invalidates previously cached verdicts automatically.
- Send
"cache": falseto force a fresh evaluation, for example when measuring threshold candidates.
Retry safety
Evaluation has no side effects and the same input yields the same verdict for a given ruleset version, so retrying a
failed or timed-out request is safe: there is nothing to duplicate and no state to reconcile. An
Idempotency-Key header is accepted and ignored, so it is safe to send one from a generic HTTP client.
Budgets and usage
Your integration owner can attach a spend budget to a credential. Spend is the evaluation cost of the calls your credential makes, measured per UTC day and per calendar month. Cached verdicts are free.
Behaviour under load
Budgets are enforced at each serving location from a spend view refreshed about every 30 seconds, so a burst of
concurrent traffic can overshoot a budget slightly before enforcement catches up. Treat warning as the
signal to act.
if (res.headers.get("x-budget-status") === "warning") {
metrics.increment("moderation.budget_warning");
}
if (res.status === 402) {
return "review"; // budget reached: degrade, never drop
}
Rate limits and headers
Limits are expressed per credential in requests per second and enforced at each serving location, which makes them
approximate in aggregate and generous in burst. Read your current limit from GET /api/v1/me or the
RateLimit-Limit response header.
- Exceeding the limit returns
429with coderate_limit_exceeded. - Back off with jitter. A batch of 50 counts as a single request, so batching is the cheapest way to raise throughput.
- Sustained higher ceilings are a configuration change — request one through your integration owner.
Response headers
Recommended back-off
async function withRetry(fn, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (e) {
const retryable = e.status === 429 || e.status >= 500;
if (!retryable || i === attempts - 1) throw e;
const base = 200 * 2 ** i; // 200ms, 400ms, 800ms
await sleep(base + Math.random() * base); // full jitter
}
}
}
Do not retry 4xx responses other than 429: they describe a problem
with the request, or a budget, that a retry cannot fix.
Error handling
Every failure uses one envelope with a stable code you can branch on. type groups codes into
families, param names the offending field when applicable, and request_id identifies the call.
{
"error": {
"type": "invalid_request_error",
"code": "batch_too_large",
"message": "A batch may contain at most 200 items (received 340).",
"param": "items",
"docs": "https://acs-lite.api.efficientstack.com/docs#errors",
"request_id": "req_8ZQ1nT4pKcVm"
}
}
Error families
Reference
Fail open or fail closed
Decide this deliberately and write it down. Most teams fail open into review: on a 5xx or
402, publish optimistically and queue the item for a human, rather than blocking a legitimate contribution
or letting an unchecked item through silently.
try {
const v = await moderate(text);
return v.decision; // allow | review | block
} catch (e) {
if (e.status === 402 || e.status === 429 || e.status >= 500) {
metrics.increment("moderation.degraded");
return "review"; // degrade, never drop
}
throw e; // other 4xx is an integration defect: surface it
}
Integration patterns
Inline, request-time
Call /check on the write path before persisting the item. Use a short client timeout (2–3 seconds) and
degrade to review on failure. Store the verdict alongside the item.
POST /api/v1/check -> allow -> publish
-> review -> persist as pending + enqueue
-> block -> reject with a user-facing message
Asynchronous queue
Accept the item immediately, then evaluate in a worker with /batch. This removes moderation latency from
the user's request entirely and is the highest-throughput arrangement.
while (const chunk = queue.take(50)) {
const { results } = await batch(chunk.map((c) => ({ id: c.id, content: c.body })));
for (const r of results) {
if (r.decision === "error") queue.requeue(chunk[r.index]);
else applyVerdict(r.id, r);
}
}
Re-scan after a policy change
When ruleset.version increments, previously evaluated content may receive a different verdict. Re-scan
the affected window with /batch and compare against the stored verdict to produce an exception report.
const fresh = await batch(items);
const changed = fresh.results.filter(
(r, i) => r.decision !== stored[i].decision
);
Reviewer interface
Render rules and reason for the moderator, order the queue by score then
confidence, and display ruleset.id and ruleset.version so decisions remain auditable
after policy changes. Request include: ["probabilities"] when reviewers benefit from seeing near-miss rules.
Shadow evaluation
To validate a policy change without user impact, keep enforcing the current verdict while recording a second evaluation with candidate thresholds supplied per request. Compare the two streams before asking for the change to be published to your ruleset.
Data handling
The service is designed to hold as little of your content as possible.
Untrusted input
Submitted content is treated strictly as data, never as instruction. Do not prepend framing, usernames or directives
to the text you send: it adds noise, changes probabilities, increases token usage and reduces cache reuse. Send the item
exactly as the user wrote it and express everything else through context.
Minimisation
Send only the field being moderated. If you must correlate a verdict with your own records, use id,
metadata or client_id rather than embedding identifiers in the content itself — those fields
are echoed back and excluded from evaluation.
Code examples
Minimal, production-shaped clients. Each one reads the credential from the environment, fails fast on
4xx and degrades on 5xx.
const BASE = "https://acs-lite.api.efficientstack.com/api/v1";
export async function moderate(content, { signal } = {}) {
const res = await fetch(`${BASE}/check`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ACS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ content }),
signal,
});
if (!res.ok) {
const { error } = await res.json();
const err = new Error(`${error.code}: ${error.message}`);
err.status = res.status;
err.requestId = error.request_id;
throw err;
}
return res.json();
}
const verdict = await moderate(comment.body);
switch (verdict.decision) {
case "block": return reject(comment, verdict.rules[0]?.id);
case "review": return queueForModerator(comment, verdict);
default: return publish(comment);
}
import os, httpx
client = httpx.Client(
base_url="https://acs-lite.api.efficientstack.com/api/v1",
headers={"Authorization": f"Bearer {os.environ['ACS_API_KEY']}"},
timeout=httpx.Timeout(15.0, connect=3.0),
)
def moderate_many(comments: list[str]) -> list[dict]:
"""Evaluate any number of items, 200 per request."""
out: list[dict] = []
for i in range(0, len(comments), 200):
r = client.post("/batch", json={"items": comments[i:i + 200]})
r.raise_for_status()
out.extend(r.json()["results"])
return out
for result in moderate_many(["nice work", "i know where you live"]):
print(result["index"], result["decision"], [r["id"] for r in result["rules"]])
<?php
function acs_check(string $content): array {
$ch = curl_init('https://acs-lite.api.efficientstack.com/api/v1/check');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ACS_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['content' => $content]),
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 500) {
return ['decision' => 'review', 'degraded' => true];
}
if ($code !== 200) {
throw new RuntimeException("moderation failed ({$code}): {$body}");
}
return json_decode($body, true);
}
$verdict = acs_check($comment);
if ($verdict['decision'] === 'block') { /* reject */ }
package moderation
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type Verdict struct {
Decision string `json:"decision"`
Flagged bool `json:"flagged"`
Score int `json:"score"`
Confidence float64 `json:"confidence"`
Rules []struct {
ID string `json:"id"`
Probability float64 `json:"probability"`
Action string `json:"action"`
} `json:"rules"`
}
var client = &http.Client{Timeout: 5 * time.Second}
func Check(ctx context.Context, content string) (*Verdict, error) {
body, _ := json.Marshal(map[string]string{"content": content})
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
"https://acs-lite.api.efficientstack.com/api/v1/check", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("ACS_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("moderation failed: %s", res.Status)
}
var v Verdict
return &v, json.NewDecoder(res.Body).Decode(&v)
}
Batch helper
Chunking, bounded parallelism and per-item retry in a dozen lines:
async function moderateAll(items, { chunk = 50, parallel = 4 } = {}) {
const chunks = [];
for (let i = 0; i < items.length; i += chunk) chunks.push(items.slice(i, i + chunk));
const out = [];
for (let i = 0; i < chunks.length; i += parallel) {
const batches = await Promise.all(
chunks.slice(i, i + parallel).map((c) => withRetry(() => batch(c)))
);
for (const b of batches) out.push(...b.results);
}
return out;
}
Best practices
- Store the whole verdict, not just the outcome. Keep
decision,rules,confidence,ruleset.idandruleset.version. When policy changes you can explain, and re-score, every past decision. - Use the review tier. It exists so you never have to choose between blocking too much and missing too much.
- Batch anything asynchronous. Backfills, re-scans and queue workers belong on
/batch; reserve/checkfor the request path. - Set a client timeout on the inline path and degrade rather than wait. A moderation call should never be the reason a page hangs.
- Send only the content. No prompts, no usernames, no surrounding markup.
- Pin a rule subset for narrow surfaces to cut latency and cost.
- Cache
GET /rulesand refresh on version change rather than per request. - One credential per workload. Independent ceilings, rulesets, budgets and revocation, attributable usage.
- Alert on degradation, not only on errors: a rising
reviewrate, a falling cache hit rate or anX-Budget-Status: warningis usually the first sign of a shift.
Versioning
Two independent versions matter to an integration.
What counts as non-breaking
- New response fields, new optional request fields, new response headers.
- New rules, renamed rule display names, changed thresholds, changed rule criteria, a changed ruleset assignment.
- New error codes within an existing
typefamily, and new families for new features.
Write clients that ignore unknown fields and branch on code rather than on message text, and these
changes will never require a release on your side.
Changelog
Support
Include the X-Request-Id of a failing call, the HTTP status
and the error code. That is sufficient to locate the request; please do not send customer content.
Rate ceilings, budgets, batch ceilings, rulesets, rule text and thresholds are configuration. Requests for changes go through your integration owner and require no client release.
Probe /api/v1/health for reachability. Service levels, maintenance windows and escalation paths are defined in your agreement.