Skip to main content

ToxicFilter Python SDK

ToxicFilter Python SDK

The official Python client for ToxicFilter.

pip install toxicfilter-sdk
from toxicfilter import Client

tf = Client(os.environ["TOXICFILTER_KEY"])

verdict = tf.text("Check this message", locales=["en"], surface="comment", reference="comment_9931")

if verdict.blocked:
    refuse(verdict.reason)  # the first reason; verdict.reasons has them all
elif verdict.needs_review:
    hold(verdict.id, verdict.reasons)
else:
    publish()

Three decisions, not two. review is where the uncertainty is allowed to live: forced to choose between publishing and deleting, a threshold set safely deletes real posts and one set kindly publishes the abuse. There is no is_toxic here for the same reason: fifteen categories collapsed into one boolean is somebody else's policy in your code.

What it does for you

Retries the right failures and never the wrong one. A 429 or a 5xx is asked again with a growing wait; QuotaExhausted is not, ever. Those two are constantly confused and the correct behaviour is opposite: retry a rate limit, stop dead on a quota.

Makes those retries safe. Every call carries an Idempotency-Key, generated per call, so a request that timed out and is asked again is judged once and billed once.

Never reads a non-answer as allow. Only a 2xx whose body is a JSON object is an answer. A redirect (never followed, so your key goes nowhere it was not sent), an empty body or a proxy's HTML page is a retryable ServerError, and a verdict without a decision raises rather than defaulting to publish.

Waits as long as the service asked, and no longer. A 429 carries retry_after, and honouring it beats guessing: the service knows when its own window turns over. It is bounded by max_wait (30 seconds by default) all the same, because a number on the wire should not decide how long your own call hangs.

from toxicfilter import QuotaExhausted, RateLimited

try:
    verdict = tf.text(comment)
except QuotaExhausted as e:
    # Stop calling. No amount of retrying produces credits.
    alert(f"out of credits: {e.remaining} left, needed {e.required}, renews {e.renews_at}")
except RateLimited:
    # Already retried, and still too many. Back off properly.
    ...

When the model is down

A verdict reached without the model because the provider was failing comes back with degraded set, and is billed as the cheap call. It is a separate field from used_ai on purpose: one says the cheap detectors were enough, the other says nobody read it, and only the first is reassuring. Hold or queue what matters to you when you see it.

Projects

An organization can moderate several sites, one project each. Name the project and the verdict is filed there, with its own activity, review queue and webhooks; leave it out and it goes to your default project. The keys and the credits are the organization's.

verdict = tf.text(comment, project="forum")
verdict.project  # "forum"

tf.batch(items, project="forum")    # the whole batch, on the envelope
tf.records(project="forum")         # one project's queue
tf.batches(project="forum")         # its recent batches

A project that does not exist is refused with an InvalidRequest (unknown_project).

Rules without a policy

Send the line you care about and nothing else is acted on. No stored policy is looked up, and no default of ours is laid underneath.

verdict = tf.text(comment, rules={"thresholds": {"sexual": {"block": 0.7}}})

A category you did not mention still scores and still appears in signals; it just does not decide anything. A name that is not a real category, subject or lead type is a 422: a line that acts on nothing looks exactly like a line that works.

Send rules together with a policy and they are laid over it instead: the call wins for what it names, the policy keeps everything else, and words are added to its lists. verdict.policy then says "overridden": True.

verdict = tf.text(comment, policy="comments", rules={"thresholds": {"spam": {"block": 0.6}}})

The rest of the answer

verdict.redacted   # the content with the personal data masked, when you asked
verdict.context    # repeats, near-duplicates, the actor's record and what it moved
verdict.shadow     # what a policy you are trialling would have said. Never what happened
verdict.facts      # noticed, not a finding: a language, an age signal, a fingerprint
verdict.degraded   # part of the pipeline could not run
verdict.model      # asked for the model and deliberately not run, and why; or None
verdict.renews_at  # when the monthly allowance comes back

redacted is usually worth more than a refusal: throwing a whole comment away because it carried one phone number throws away everything else the person wrote.

verdict = tf.text(comment, redact=True, actor="user_8812")

publish(verdict.redacted or comment)

Who is writing

Every verdict also carries leads: what kind of lead wrote it, scored per type. Neither a harm nor a subject, but who is on the other side and what they want.

verdict = tf.conversation(messages, rules={"leads": {"free_work_for_equity": {"block": 0.6}}})

verdict.leads                 # {"free_work_for_equity": 0.9, "no_budget": 0.7}
verdict.lead("sales_pitch")   # 0.0 when nothing of that type showed
verdict.blocked               # True: your rule discarded it

Types: free_work_for_equity, no_budget, unrealistic_expectations, free_consulting, scope_creep, no_show, sales_pitch, partnership_offer, job_seeker, student_or_survey, support_request. Send the conversation rather than one message and everything that person said counts.

Everything else

tf.email("someone@mailinator.com")
tf.name("asdkjhasd")
tf.signup(name="Ana", email="ana@example.com", bio="...")
tf.image("https://cdn.example.com/photo.jpg")
tf.image(base64_or_data_uri)                    # or the bytes, if you have not published it
tf.image_data(open("a.jpg", "rb").read())       # the same thing, said explicitly
tf.url("https://bit.ly/3xYz")                   # one link, judged as a link
tf.prompt(what_the_user_typed_into_your_chatbot)   # prompt injection, plus everything else

# A message with what came before it. A pile-on is thirty people each writing one
# ordinary rude sentence, and no classifier reading one of them can see it.
tf.conversation([
    {"author": "u1", "content": "..."},
    {"author": "u2", "content": "..."},
    {"author": "u5", "content": "the one being judged"},
], locales=["es"])

# Many things in one call. One bad item is an item, not a batch.
batch = tf.batch([
    {"kind": "text", "content": "...", "reference": "c_1"},
    {"kind": "image", "url": "...", "reference": "p_2"},
], ai=False)

for index, verdict in batch.verdicts.items(): ...
for index, error in batch.failures.items(): ...
batch.unplaced_failures   # failures that name no item, such as a lost async chunk

# A backfill: queued, answered immediately, polled or webhooked. Up to 1,000 items per
# call (100 for a sync batch), so a bigger backfill goes out in slices.
for start in range(0, len(comments), 1000):
    queued = tf.batch_async(comments[start:start + 1000])
    queued.status_url   # where to poll it; `batch.completed` is the webhook
    queued.summary      # allow / review / block / failed, over the whole batch

# A backfill read back a page at a time. A cursor, not an offset: rows appear as workers
# finish them, so an offset skips whatever was inserted behind it.
page = tf.batch_status(queued.id)

while page.has_more:
    page = tf.batch_status(queued.id, after=page.next_after)

# The review queue.
queue = tf.records(state="open")
tf.resolve(record_id, "approved", moderator="ana@example.com")
tf.feedback(record_id, "false_positive")   # free, and the only honest measure we have

# And what a held verdict has had done to it. `record()`, `resolve()` and `feedback()`
# answer with the whole stored verdict; the `records()` listing does not carry `review`.
verdict = tf.record(record_id)
verdict.review_state   # open, approved or rejected
verdict.resolved_by    # your own name for whoever decided
verdict.feedback       # what you already told us, or None
verdict.content        # only when your policy keeps it, and only until it expires

tf.keys()                     # prefixes, modes, last use. Never a secret.
tf.revoke_key(key_id)         # including the one you are calling with. There is no create.

tf.usage()   # credits, windows, prices. Works at zero credits.
tf.ping()

Webhooks

Your endpoint URL is public. Verify before you act:

from toxicfilter import webhooks

event = webhooks.event(
    request.get_data(),                                  # the RAW body
    request.headers["X-ToxicFilter-Signature"],
    os.environ["TOXICFILTER_WEBHOOK_SECRET"],
)

if event is None:
    return "", 400

Notes

Python 3.9+. No dependencies: the standard library does the HTTP. A client for one small API is not worth a dependency tree, and pinning requests or httpx is how a client gets removed from a project. Bring your own by passing anything with a send() as transport=.

How this is tested

The suite drives the client through a stub transport and covers the parts a caller cannot see: what is retried, what never is, and that a retry reuses its idempotency key while two separate calls do not.

The API contract itself is pinned on the other side, by the ToxicFilter application's own suite, which runs the PHP client against its real routes. Recorded fixtures would agree with the API on the day they were written and drift silently afterwards.

Author

Created by Edu Lazaro for ToxicFilter, the moderation API this client speaks to.

License

The ToxicFilter Python SDK is open-sourced software licensed under the MIT license.

Release files for toxicfilter-sdk 1.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for toxicfilter-sdk 1.2.0
File Size Uploaded
toxicfilter_sdk-1.2.0.tar.gz 36.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for toxicfilter-sdk 1.2.0
File Interpreter ABI Platform
toxicfilter_sdk-1.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 58.7 kB

Release files / toxicfilter_sdk-1.2.0.tar.gz

Download URL toxicfilter_sdk-1.2.0.tar.gz
Size 36.0 kB
Tags Source
SHA-256 checksum
How to use checksums
8d73bfad0bf294e6be03a18b72bb0177df4ddb7de84a7e28a6c23696eb7ff5e6
BLAKE2b-256 checksum
How to use checksums
1ab47d155844fd80a4524ff36bb046c4c0014be1189180a87830ca589aaac012
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release files / toxicfilter_sdk-1.2.0-py3-none-any.whl

Download URL toxicfilter_sdk-1.2.0-py3-none-any.whl
Size 22.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fd1e28eed7b2a681e790e8d24ef294bce7f3d9d26c1a8404df4b19933c5cc6db
BLAKE2b-256 checksum
How to use checksums
bd7df44590928ce3b0f3ee3000f84534d07e6952fa262ecb2169a76b06a0868e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release history Release notifications | RSS feed

1.2.5

2 release files

1.2.4

2 release files

This release

1.2.0 This release

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page