Authentication
Every request goes to https://turnitout.org/v1 and carries your key in the Authorization header. Keys start with tio_live_ and are shown once, when you create them in your account — store them like a password, on your server only, never in a browser or mobile app.
The API uses no cookies and no CSRF header. A missing, wrong or revoked key answers 401 unauthorized.
curl https://turnitout.org/v1/usage \
-H "Authorization: Bearer $TURNITOUT_KEY"
Humanize a text
Send the text as JSON. The job is queued at once and answers 202; the rewrite usually finishes within a minute. Follow it with GET or a webhook.
| Field | Type | Description |
|---|---|---|
text | string | Required. The text to humanize. |
level | string | Required. light, balanced or strong — see the lines below. |
language | string | Optional. en or es — replaces the automatic language detection. Without it, the language is detected from the text. |
| Level | AI score line | Minimum words | Use it for |
|---|---|---|---|
light | ≤ 30 % | 100 | Keeps the wording close and rewrites only what sounds machine-made. |
balanced | ≤ 15 % | 100 | Rewrites more freely — the right choice for fully AI-drafted text. |
strong | ≤ 5 % | 500 | Goes furthest and aims for the lowest score. |
curl https://turnitout.org/v1/humanize \
-H "Authorization: Bearer $TURNITOUT_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: essay-8841" \
-d '{"text": "The text to humanize…", "level": "balanced"}'
import os
import requests
API = "https://turnitout.org/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['TURNITOUT_KEY']}"}
r = requests.post(f"{API}/humanize", headers=HEADERS, json={
"text": open("essay.txt", encoding="utf-8").read(),
"level": "balanced",
})
r.raise_for_status()
job = r.json()
print(job["id"], job["status"], job["words"])
// Node 18+ (fetch is built in)
const API = "https://turnitout.org/v1";
const headers = { Authorization: `Bearer ${process.env.TURNITOUT_KEY}` };
const res = await fetch(`${API}/humanize`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ text, level: "balanced" }),
});
if (!res.ok) throw new Error((await res.json()).error.code);
const job = await res.json();
console.log(job.id, job.status, job.words);
{
"id": "to_A1b2C3d4E5f6G7h8I9j0",
"status": "queued",
"level": "balanced",
"words": 1200,
"part_count": 1,
"created_at": "2026-11-05T17:09:21Z"
}
Upload a document
Send a .docx or .txt file as file, with level as a form field. For a Word file you get the same document back: headings, lists, tables, styles, headers and footers stay exactly as uploaded, and only the rewritten text changes.
curl https://turnitout.org/v1/humanize \
-H "Authorization: Bearer $TURNITOUT_KEY" \
-F "file=@essay.docx" \
-F "level=light"
with open("essay.docx", "rb") as f:
r = requests.post(f"{API}/humanize", headers=HEADERS,
files={"file": ("essay.docx", f)},
data={"level": "light"})
r.raise_for_status()
job = r.json()
import { readFile } from "node:fs/promises";
const form = new FormData();
form.append("file", new Blob([await readFile("essay.docx")]), "essay.docx");
form.append("level", "light");
const res = await fetch(`${API}/humanize`, { method: "POST", headers, body: form });
const job = await res.json();
Get the result
A job moves from queued to running to completed or failed. Poll every 2–3 seconds, or skip polling and let a webhook tell you. Once completed, the answer includes the scores and output_text.
curl https://turnitout.org/v1/humanize/to_A1b2C3d4E5f6G7h8I9j0 \
-H "Authorization: Bearer $TURNITOUT_KEY"
import time
while True:
job = requests.get(f"{API}/humanize/{job_id}", headers=HEADERS).json()
if job["status"] in ("completed", "failed"):
break
time.sleep(3)
if job["status"] == "completed":
print(job["ai_before"], "->", job["ai_after"], job["reached_target"])
print(job["output_text"])
else:
print(job["error"])
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let job;
do {
await sleep(3000);
job = await (await fetch(`${API}/humanize/${id}`, { headers })).json();
} while (job.status === "queued" || job.status === "running");
if (job.status === "completed") console.log(job.ai_after, job.output_text);
else console.error(job.error);
{
"id": "to_A1b2C3d4E5f6G7h8I9j0",
"status": "completed",
"level": "balanced",
"language": "en",
"words": 1200,
"part_count": 1,
"ai_before": 87,
"ai_after": 4,
"target_pct": 15,
"reached_target": true,
"words_changed_pct": 41.2,
"error": null,
"created_at": "2026-11-05T17:09:21Z",
"completed_at": "2026-11-05T17:10:02Z",
"output_text": "The rewritten text…"
}
| Field | Description |
|---|---|
status | queued, running, completed or failed. |
words | The words counted for this job. |
language | en or es — the one you sent, or the one detected. |
part_count | How many parts a long document was humanized in (1 for a single text). |
ai_before / ai_after | AI score of the text before and after, 0–100, from our detector trained on Turnitin’s own reports. ai_after can be null if scoring was unavailable. |
target_pct | The line for the chosen level (30, 15 or 5). |
reached_target | true when ai_after is at or below the line. |
words_changed_pct | How much of the text was rewritten, in percent. |
cost_cents | What the job costs at your agreed rate, in US cents, set once it completes. Failed jobs cost 0. |
error | null, or {"code", "message"} when the job failed. |
output_text | The humanized text, once completed. |
Download the output
format=text returns plain text. format=docx returns a Word document — the uploaded .docx with only the rewritten text changed, or a clean document for typed text. Add highlight=true to mark the rewritten words in yellow. Asking before the job has completed answers 409 not_ready with the job’s current job_status.
curl "https://turnitout.org/v1/humanize/to_A1b2C3d4E5f6G7h8I9j0/output?format=docx" \
-H "Authorization: Bearer $TURNITOUT_KEY" \
-o essay.humanized.docx
r = requests.get(f"{API}/humanize/{job_id}/output", headers=HEADERS,
params={"format": "docx", "highlight": "true"})
r.raise_for_status()
with open("essay.humanized.docx", "wb") as f:
f.write(r.content)
import { writeFile } from "node:fs/promises";
const res = await fetch(`${API}/humanize/${id}/output?format=docx`, { headers });
if (res.status === 409) throw new Error("not ready yet");
await writeFile("essay.humanized.docx", Buffer.from(await res.arrayBuffer()));
List your jobs
Your jobs, newest first, in the same shape as GET /v1/humanize/{id} without the texts. To page back, pass the last id you received as before.
curl "https://turnitout.org/v1/humanize?limit=20" \
-H "Authorization: Bearer $TURNITOUT_KEY"
Usage
Words, jobs and cost for a month (this month when month is left out) — the same figures as the API area of your account. Jobs count in the month they completed, in UTC.
curl "https://turnitout.org/v1/usage?month=2026-11" \
-H "Authorization: Bearer $TURNITOUT_KEY"
Webhooks
Set a webhook address in your account (API → Webhook). Saving it shows a signing secret once; saving the address again makes a new secret. When a job finishes we POST it to your address as JSON:
POST /your-webhook HTTP/1.1
Content-Type: application/json
X-TurnItOut-Event: humanize.completed
X-TurnItOut-Signature: t=1762362602,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"event": "humanize.completed",
"data": { "id": "to_A1b2C3d4E5f6G7h8I9j0", "status": "completed", "ai_after": 4, … }
}
eventishumanize.completedorhumanize.failed;datais the job, as from GET /v1/humanize/{id}.- The first delivery goes out within about 10 seconds of the job ending. Answer with any
2xxstatus; otherwise we retry three times: after 1 minute, 10 minutes and 1 hour. - Webhook addresses must start with
https://. - A retry can deliver the same event twice — handle each job
idonce.
Verify the signature
X-TurnItOut-Signature holds a Unix timestamp t and v1, the hex HMAC-SHA256 of t, a dot and the raw request body, keyed with your signing secret. Compute it over the exact bytes you received (before any JSON parsing), compare in constant time, and reject old timestamps.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.TURNITOUT_WEBHOOK_SECRET;
function verify(header, rawBody, toleranceSeconds = 300) {
const parts = Object.fromEntries((header || "").split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${parts.t}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// keep the raw body: the signature covers the exact bytes we sent
app.post("/turnitout-webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.get("X-TurnItOut-Signature"), req.body)) return res.sendStatus(400);
const { event, data } = JSON.parse(req.body.toString("utf8"));
// handle data.id once: a retry can deliver the same event again
res.sendStatus(200);
});
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["TURNITOUT_WEBHOOK_SECRET"].encode()
def verify(header: str, body: bytes, tolerance: int = 300) -> bool:
try:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
signed = parts["t"].encode() + b"." + body
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
app = Flask(__name__)
@app.post("/turnitout-webhook")
def turnitout_webhook():
if not verify(request.headers.get("X-TurnItOut-Signature", ""), request.get_data()):
abort(400)
event = request.get_json()
# handle event["data"]["id"] once: a retry can deliver the same event again
return "", 200
Idempotency
Networks fail. Send an Idempotency-Key header — any unique string up to 128 characters, such as your own document id — with POST /v1/humanize, and a retry with the same key within 24 hours returns the job that was already created instead of starting (and billing) a second one. Keys are kept per client; reusing a key with a different request answers 409 idempotency_conflict.
Idempotency-Key: order-2291-essay
Limits
| Requests | 60 a minute per key; more answers 429 rate_limited. |
|---|---|
| Words per text | Up to 10,000. |
| Documents | Up to 50,000 words, humanized in up to 10 parts and returned as one document. |
| Minimum | 100 words for light and balanced, 500 for strong. |
| Files | .docx and .txt. |
| Languages | English and Spanish. |
Errors
Errors use the HTTP status and a JSON body with a stable code to branch on and an English message.
{
"error": {
"code": "too_short",
"message": "This strength needs at least 500 words."
}
}
| Code | HTTP | What it means |
|---|---|---|
unauthorized | 401 | The key is missing, wrong or revoked. |
client_disabled | 403 | API access is switched off for your account. Write to us. |
rate_limited | 429 | More than 60 requests in a minute on this key. Wait and retry. |
bad_level | 400 | level isn’t light, balanced or strong. |
too_short | 400 | Fewer words than the level needs. |
text_too_long | 413 | More than 10,000 words in a text, or 50,000 in a document. |
unsupported_language | 400 | The text isn’t English or Spanish. |
unsupported_file | 400 | The file isn’t a .docx or .txt. |
not_found | 404 | No job with that id for your keys. |
not_ready | 409 | The output was asked for before the job completed; job_status says where it is. |
idempotency_conflict | 409 | The Idempotency-Key was already used with a different request. |
paused | 503 | The humanizer is paused for a short update. Retry in a few minutes. |
Billing
Usage is billed monthly, by the words humanized, at the rate agreed when your access is approved. Each month is invoiced at the start of the next one, and your invoices and running total are in your account.
- Failed jobs cost nothing.
- With the score guarantee on your account, a job whose
ai_afterlands above its level’s line costs nothing either.
Ready to build? Write to hello@turnitout.org — TurnItOut API keys are issued to approved businesses.