Developers · API v1

The TurnItOut API.

Humanize text and Word documents from your own product. The same engine as turnitout.org — an AI score before and after on every result, the same strength lines, and your exact document back — over HTTPS and JSON.

How to get access

TurnItOut API keys are issued to approved businesses — write to hello@turnitout.org with your company, what you’d like to build and the volume you expect. Once you’re approved, your TurnItOut account gets an API area where you create keys, set a webhook and follow your usage and monthly invoices.

01

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.

Request
curl https://turnitout.org/v1/usage \
  -H "Authorization: Bearer $TURNITOUT_KEY"
02

Humanize a text

POST/v1/humanize

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.

FieldTypeDescription
textstringRequired. The text to humanize.
levelstringRequired. light, balanced or strong — see the lines below.
languagestringOptional. en or es — replaces the automatic language detection. Without it, the language is detected from the text.
LevelAI score lineMinimum wordsUse it for
light≤ 30 %100Keeps the wording close and rewrites only what sounds machine-made.
balanced≤ 15 %100Rewrites more freely — the right choice for fully AI-drafted text.
strong≤ 5 %500Goes 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);
Response · 202
{
  "id": "to_A1b2C3d4E5f6G7h8I9j0",
  "status": "queued",
  "level": "balanced",
  "words": 1200,
  "part_count": 1,
  "created_at": "2026-11-05T17:09:21Z"
}
03

Upload a document

POST/v1/humanizemultipart/form-data

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();
04

Get the result

GET/v1/humanize/{id}

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);
Response · 200
{
  "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…"
}
FieldDescription
statusqueued, running, completed or failed.
wordsThe words counted for this job.
languageen or es — the one you sent, or the one detected.
part_countHow many parts a long document was humanized in (1 for a single text).
ai_before / ai_afterAI 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_pctThe line for the chosen level (30, 15 or 5).
reached_targettrue when ai_after is at or below the line.
words_changed_pctHow much of the text was rewritten, in percent.
cost_centsWhat the job costs at your agreed rate, in US cents, set once it completes. Failed jobs cost 0.
errornull, or {"code", "message"} when the job failed.
output_textThe humanized text, once completed.
05

Download the output

GET/v1/humanize/{id}/output?format=text|docx

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()));
06

List your jobs

GET/v1/humanize?limit=20&before={id}

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.

Request
curl "https://turnitout.org/v1/humanize?limit=20" \
  -H "Authorization: Bearer $TURNITOUT_KEY"
07

Usage

GET/v1/usage?month=YYYY-MM

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.

Request
curl "https://turnitout.org/v1/usage?month=2026-11" \
  -H "Authorization: Bearer $TURNITOUT_KEY"
08

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:

What we send
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, … }
}
  • event is humanize.completed or humanize.failed; data is the job, as from GET /v1/humanize/{id}.
  • The first delivery goes out within about 10 seconds of the job ending. Answer with any 2xx status; 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 id once.

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
09

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.

Header
Idempotency-Key: order-2291-essay
10

Limits

Requests60 a minute per key; more answers 429 rate_limited.
Words per textUp to 10,000.
DocumentsUp to 50,000 words, humanized in up to 10 parts and returned as one document.
Minimum100 words for light and balanced, 500 for strong.
Files.docx and .txt.
LanguagesEnglish and Spanish.
11

Errors

Errors use the HTTP status and a JSON body with a stable code to branch on and an English message.

Response · 400
{
  "error": {
    "code": "too_short",
    "message": "This strength needs at least 500 words."
  }
}
CodeHTTPWhat it means
unauthorized401The key is missing, wrong or revoked.
client_disabled403API access is switched off for your account. Write to us.
rate_limited429More than 60 requests in a minute on this key. Wait and retry.
bad_level400level isn’t light, balanced or strong.
too_short400Fewer words than the level needs.
text_too_long413More than 10,000 words in a text, or 50,000 in a document.
unsupported_language400The text isn’t English or Spanish.
unsupported_file400The file isn’t a .docx or .txt.
not_found404No job with that id for your keys.
not_ready409The output was asked for before the job completed; job_status says where it is.
idempotency_conflict409The Idempotency-Key was already used with a different request.
paused503The humanizer is paused for a short update. Retry in a few minutes.
12

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_after lands above its level’s line costs nothing either.

Ready to build? Write to hello@turnitout.org — TurnItOut API keys are issued to approved businesses.