API
The API is part of the Pro plan. It runs the same detector and humanizer as the website, with the same word caps and daily allowances (100,000 words per request). Scores are TrueScribe's own estimates, not proof of authorship, and nothing here guarantees what any other detector will say.
Machine-readable description: /v1/openapi.json (OpenAPI 3.1). Every example on this page was run against a local server before it was published.
Keys
Log in, open Account, then API keys and press Create key. The key is shown once; we store only a hash of it, so we cannot show it again. You can have up to 5 active keys and revoke any of them at once. Keep keys on your server, never in a web page or an app you ship.
Send the key on every request as Authorization: Bearer ts_live_.... Cookies are not used on /v1. Your texts sent through the API are not saved to history and are not used for training.
Detect: POST /v1/detect
Body: text (required), extra (also run the optional open-source detectors, if the server has them), first_words_only. Normal-length texts return 200 with the result. Very long texts return 202 with a job to poll (see below). Texts under 50 words return status: "too_short" and are not charged.
#!/bin/sh
# Score a text. Set TRUESCRIBE_KEY (your key) and TRUESCRIBE_URL (for example https://truescribe.example).
curl -sS "$TRUESCRIBE_URL/v1/detect" \
-H "Authorization: Bearer $TRUESCRIBE_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "In today'"'"'s fast-paced world, it is important to note that artificial intelligence plays a crucial role in shaping the future of communication. Moreover, leveraging scalable solutions can significantly enhance overall productivity across various industries. I walked to the market this morning and the baker was out of rye, so I bought two odd rolls and ate one on the bench by the river. It rained a little. Nobody minded."}'
Reply, trimmed:
{
"api_version": "1",
"schema_version": "2.0",
"status": "ok",
"detector_version": "2.0.0-untrained",
"word_count": 83,
"ai_probability": 0.551,
"label": "mixed",
"breakdown": { "ai": 0.593, "mixed": 0.0, "human": 0.407 },
"sentences": [ { "text": "In today's fast-paced world, ...", "start": 0, "end": 142, "score": 0.933, "label": "ai" } ],
"reasons": [ { "signal": "stylometry", "explanation": "Uses the stock phrase \u201cseamless\u201d, which AI text uses far more often than people do.", "quotes": [ ... ] } ],
"confidence": "low",
"warnings": [ "No trained detector model is installed on this server, so this is an untrained writing-style estimate. Treat it as a rough hint." ],
"engine": { "signals": ["style"], "trained": false, "calibrated": false, "validated": false }
}
Humanize: POST /v1/humanize
Body: text (required), tone (free, academic, professional, casual, creative, simple), level (highschool, undergraduate, postgraduate, professional), rewrite_level (quality, balanced, max), passes (1 to 3), style_sample (at least 50 words of your own writing), first_words_only. It always returns 202 and a job.
#!/bin/sh
# Start a humanizer job, then ask for it. The Idempotency-Key makes a retry of the first call safe.
curl -sS "$TRUESCRIBE_URL/v1/humanize" \
-H "Authorization: Bearer $TRUESCRIBE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: my-first-job" \
-d '{"text": "In today'"'"'s fast-paced world, it is important to note that artificial intelligence plays a crucial role in shaping the future of communication.", "tone": "professional", "rewrite_level": "balanced"}'
# The reply has a "poll" path such as /v1/jobs/Z7Va8U3KqvdoIPCVXdoI9w. Fetch it until "status" is "done":
# curl -sS "$TRUESCRIBE_URL/v1/jobs/Z7Va8U3KqvdoIPCVXdoI9w" -H "Authorization: Bearer $TRUESCRIBE_KEY"
HTTP/1.1 202 Accepted
{
"api_version": "1",
"job_id": "Z7Va8U3KqvdoIPCVXdoI9w",
"status": "running",
"words": 34,
"truncated_from": null,
"poll": "/v1/jobs/Z7Va8U3KqvdoIPCVXdoI9w"
}
Jobs: GET /v1/jobs/{id}
status is queued, running, done or error. On done the result is there; on error the words were not charged. Finished jobs, and the text in them, are deleted one hour after they finish. A job can only be read with the key's own account.
{
"api_version": "1",
"id": "Z7Va8U3KqvdoIPCVXdoI9w",
"kind": "humanize",
"status": "done",
"progress": 1.0,
"stage": "Done",
"result": {
"output": "...the rewritten text...",
"before_score": 0.8,
"after_score": 0.31,
"passes": 3,
"words": 34,
"verification": { "passed": true, "kept_original": 0, ... },
"usage": { "calls": 3, "tokens_in": 1180, "tokens_out": 402 },
...
}
}
Usage: GET /v1/usage
#!/bin/sh
# Your plan's caps, today's usage and this key's counters. -i also prints the X-RateLimit-* headers.
curl -sSi "$TRUESCRIBE_URL/v1/usage" -H "Authorization: Bearer $TRUESCRIBE_KEY"
{
"api_version": "1",
"plan": "pro",
"request_words": 100000,
"daily": { "detect": { "used": 0, "limit": 2000000 }, "humanize": { "used": 0, "limit": 600000 }, "crosscheck": { "used": 0, "limit": 30 } },
"resets_in": 71698,
"key": { "id": 1, "name": "docs check", "prefix": "ts_live_cF_2", "requests_today": 0, "words_today": 0, "requests_total": 0 },
"rate_limit": { "requests_per_minute": 60 }
}
key.requests_today counts requests made before this one.
Python
Needs pip install requests. Set TRUESCRIBE_KEY, and TRUESCRIBE_URL if you are not using the default.
import os
import time
import requests
BASE = os.environ.get("TRUESCRIBE_URL", "https://truescribe.example")
HEADERS = {"Authorization": "Bearer " + os.environ["TRUESCRIBE_KEY"]}
def call(method, path, **kw):
"""One request, waiting and retrying once if we are rate limited."""
for attempt in range(2):
r = requests.request(method, BASE + path, headers={**HEADERS, **kw.pop("headers", {})}, timeout=60, **kw)
if r.status_code != 429 or attempt:
return r
time.sleep(int(r.headers.get("Retry-After", "5")))
text = "In today's fast-paced world, it is important to note that artificial intelligence plays a crucial role in shaping the future of communication. " * 3
# 1. Detect (answers at once for normal-length texts)
r = call("POST", "/v1/detect", json={"text": text})
r.raise_for_status()
d = r.json()
print("AI probability:", d["ai_probability"], "| label:", d["label"], "| words:", d["word_count"])
# 2. Humanize: starts a job, then poll it. The Idempotency-Key makes a retry safe: it cannot charge you twice.
r = call("POST", "/v1/humanize", json={"text": text, "tone": "professional"}, headers={"Idempotency-Key": "docs-example-1"})
r.raise_for_status()
job = r.json()
while True:
j = call("GET", job["poll"]).json()
if j["status"] in ("done", "error"):
break
time.sleep(1)
print(j["status"], "|", (j["result"]["output"][:60] + "...") if j["status"] == "done" else j["error"])
# 3. Usage
u = call("GET", "/v1/usage").json()
print("humanizer words today:", u["daily"]["humanize"]["used"], "of", u["daily"]["humanize"]["limit"])
Rate limits
60 requests per minute per key (the server may be set differently; /v1/usage shows the number). Every reply carries these headers:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Reset: 60 (seconds until a slot frees up)
Retry-After: 12 (only on a 429)
Separately, your account has the same daily word allowance as on the website. Detect and humanize are counted apart, and the day resets at 00:00 UTC.
Idempotency
Add Idempotency-Key: <your unique string> to a POST. If the network fails and you send the same request again with the same key, you get the first reply back (with Idempotent-Replay: true) and are not charged twice. Keys are remembered for 24 hours. The same key with a different body is refused. Requests that failed are not remembered, so you can fix them and retry with the same key.
Errors
Every error has the same shape, and error.code will not change:
HTTP/1.1 401 Unauthorized
{ "api_version": "1", "error": { "code": "invalid_api_key", "message": "This API key is not valid, or it has been revoked." } }
HTTP/1.1 413 Content Too Large
{ "api_version": "1", "error": { "code": "text_too_long", "message": "This text is 100,001 words. Pro handles up to 100,000. Split it into smaller parts.",
"words": 100001, "cap": 100000, "over_by": 1, "can_truncate": true } }
| Code | HTTP | Meaning |
|---|---|---|
missing_api_key | 401 | No Authorization header. |
invalid_api_key | 401 | The key is wrong or was revoked. Revoking takes effect at once. |
pro_required | 403 | The account is not on Pro right now (for example the paid period ended). |
rate_limited | 429 | Too many requests for this key in a minute. Wait Retry-After seconds. |
daily_limit_reached | 429 | The day's word allowance is used up. It resets at 00:00 UTC (resets_in is in the reply). |
too_many_jobs | 429 | You already have the maximum number of jobs running. |
text_too_long | 413 | Over the 100,000-word cap. Send first_words_only: true to process just the first 100,000 words, or split the text. |
request_too_large | 413 | The request body is larger than 2 MB. |
invalid_request | 422 | A field is missing or wrong. error.errors lists them. |
bad_request | 400 | The JSON is valid but the request cannot be done, for example a style_sample shorter than 50 words. The message says what to change. |
bad_idempotency_key | 400 | Idempotency-Key must be 1 to 64 characters: letters, digits, _ . : - |
idempotency_key_reuse | 422 | That Idempotency-Key was already used with a different request. |
request_in_progress | 409 | A request with that Idempotency-Key is still running. |
not_found | 404 | No such job, or it belongs to someone else, or it finished more than an hour ago. |
service_busy | 503 | The queue is full. Try again in a minute. |
Versioning
Every reply has api_version. Within version 1 we only add fields; we never rename or remove one. A breaking change would be /v2, with /v1 kept running for a long notice period.