> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stealthgpt.ai/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Start with https://docs.stealthgpt.ai/api-reference/quickstart.md for REST setup and https://docs.stealthgpt.ai/api-reference/models.md for Super, Standard and Lite model selection.
> For existing drafts, use the Writer & Humanizer endpoints; for research-to-draft generation, use the Stealth Agent guides. Choose polling, webhook callbacks or MCP with https://docs.stealthgpt.ai/api-reference/workflow-patterns.md.
> For bulk humanization and editorial review, use https://docs.stealthgpt.ai/api-reference/recipes/humanize-review.md. For fixed links, citations or other approved spans, use https://docs.stealthgpt.ai/api-reference/text-preservation.md and replace each protected span with a marker in your application.
> For assistant setup and verified client status, use https://docs.stealthgpt.ai/api-reference/integrations/mcp.md. Consult the Pricing and Data Handling references for API entitlements, word accounting and processing details; web subscriptions and API plans are separate.

# Humanize drafts, then route them for review

> Runnable TypeScript and Python recipes for async humanization, batches of up to 100, signed webhooks, and an explicit editorial review handoff.

Add StealthGPT humanization to your content pipeline while keeping your publishing decisions in your own workflow. These recipes turn source drafts into **review files** containing the original, humanized candidate, run ID, actual billing metadata, and an editorial checklist. Use them for CMS drafts, agency queues, product content, or agent-generated articles.

The examples use native HTTP clients, not an SDK. They stop at review: no CMS credentials or publishing actions are included.

## Choose your recipe

| Workflow | Run mode | What happens |
| - | - | - |
| A draft or a small queue | `single` | Create one async run per draft, poll it, then save its review file. |
| A content backlog | `bulk` | Submit up to 100 items, collect every terminal result, then move to the next batch. |
| An event-driven pipeline | Optional signed webhook | Write the same review file when a callback arrives; polling remains the reconciliation fallback. |

Both languages implement the same single and bulk modes. Pick one implementation; do not run both against the same work queue.

## Prepare your drafts and API token

1. Get your token from the [Stealth API dashboard](https://www.stealthgpt.ai/api-dashboard). The examples send it in the **`api-token`** header on every create and status request.
2. [Download the sample drafts](https://docs.stealthgpt.ai/examples/review-drafts.json) and save them as `review-drafts.json`. Replace their `text` with source drafts only—no “humanize this” instructions.
3. Give every document revision a stable, unique `id`, such as `pricing-guide-v3`. The scripts use it as the idempotency key and review filename. Keep an ID and its request settings unchanged when retrying. Use a new revision ID for deliberately changed content or a fresh attempt after a failed run.

Idempotency is scoped to your account, key and request fingerprint. Changing source text, model, quality mode, output format or preservation settings can start a new run even with the same key. After an uncertain create response, resend the original request unchanged; do not switch models or generate a new key to recover it.

```bash theme={null}
export STEALTHGPT_API_TOKEN='your-api-token'
```

Keep the token on your backend. Do not put it in browser code, a committed input file, or a CMS article.

## Recipe 1: async humanization → editorial review

Copy one implementation below to `review.ts` or `review.py`. Both send `rephrase: true`, explicitly select `model: "super"`, and request plain text. A run returns `202` with a `runId` and `statusUrl`; the script saves that association before polling.

The scripts retry network failures, `429`, and `5xx` with the **same body and keys**, with a bounded number of attempts. Bulk mode also waits and retries `409`. A terminal `failed` or `cancelled` result becomes a blocked review file; it is never silently resubmitted as a new paid run.

> [tab] TypeScript · Node.js 24
>
> Save this as `review.ts`. It uses Node's built-in TypeScript support and has no package dependencies.
>
> ```typescript review.ts theme={null}
> import { createHash, randomUUID } from 'node:crypto'
> import { link, mkdir, readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises'
> import { fileURLToPath } from 'node:url'
>
> const API = 'https://www.stealthgpt.ai'
> export const REVIEW_DIR = process.env.REVIEW_DIR ?? './review-output'
> const MAX_ATTEMPTS = 5
> const MAX_BATCH = 100
> const MAX_BODY_BYTES = 4_000_000 // Leave room below the platform's 4.5 MB limit.
> const POLL_MS = 10_000
> const WAIT_MS = 45 * 60_000 // Client deadline; it does not cancel the server run.
> const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
> type Draft = { id: string; text: string }
> type RunRef = { runId: string; statusUrl: string }
> export type Entry = { draft: Draft; run: RunRef; requestHash: string }
>
> export function object(value: unknown): Record<string, unknown> {
>   if (typeof value !== 'object' || value === null || Array.isArray(value)) {
>     throw new Error('Expected a JSON object')
>   }
>   return value as Record<string, unknown>
> }
>
> function runRef(value: unknown): RunRef {
>   const run = object(value)
>   if (typeof run.runId !== 'string' || typeof run.statusUrl !== 'string') {
>     throw new Error('Invalid create response')
>   }
>   return { runId: run.runId, statusUrl: run.statusUrl }
> }
>
> export async function api(path: string, body?: unknown, key?: string) {
>   const url = new URL(path, API)
>   const prefix = '/api/stealthify/runs'
>   const suffix = url.pathname.slice(prefix.length + 1)
>   const validPath = body === undefined
>     ? url.pathname.startsWith(`${prefix}/`) && !!suffix && !suffix.includes('/')
>     : url.pathname === prefix || url.pathname === `${prefix}/bulk`
>   if (url.origin !== API || url.search || url.hash || !validPath) {
>     throw new Error('Unexpected API URL')
>   }
>   const token = process.env.STEALTHGPT_API_TOKEN
>   if (!token) throw new Error('Set STEALTHGPT_API_TOKEN')
>   const encoded = body === undefined ? undefined : JSON.stringify(body)
>   if (encoded && Buffer.byteLength(encoded) > MAX_BODY_BYTES) {
>     throw new Error('Request is too large; use fewer or shorter drafts')
>   }
>   for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
>     let response: Response
>     let detail: string
>     try {
>       response = await fetch(url, {
>         method: body === undefined ? 'GET' : 'POST',
>         headers: {
>           'api-token': token,
>           'Content-Type': 'application/json',
>           ...(key ? { 'idempotency-key': key } : {}),
>         },
>         body: encoded,
>         signal: AbortSignal.timeout(30_000),
>       })
>       detail = await response.text() // A dropped body is a transport failure too.
>     } catch (error) {
>       if (attempt === MAX_ATTEMPTS - 1) throw error
>       await sleep(1000 * 2 ** attempt)
>       continue
>     }
>     // A complete but invalid JSON response is not a transport retry.
>     if (response.ok) return object(JSON.parse(detail))
>     const retryable = response.status === 429 || response.status >= 500 ||
>       (response.status === 409 && url.pathname.endsWith('/bulk'))
>     if (!retryable || attempt === MAX_ATTEMPTS - 1) {
>       throw new Error(`HTTP ${response.status}: ${detail}`)
>     }
>     const seconds = Number(response.headers.get('Retry-After'))
>     const delay = seconds > 0 ? seconds * 1000 : Math.min(30_000, 1000 * 2 ** attempt)
>     if (delay > 60_000) throw new Error('Retry later with the same input and IDs')
>     await sleep(delay)
>   }
>   throw new Error('Retry limit reached')
> }
>
> export async function writeReview(entry: Entry, status: Record<string, unknown>) {
>   if (typeof entry.draft.id !== 'string' || !/^[A-Za-z0-9_-]{1,80}$/.test(entry.draft.id) ||
>       typeof entry.draft.text !== 'string' || typeof entry.run.runId !== 'string' || !entry.run.runId) {
>     throw new Error('Invalid review document ID or source')
>   }
>   if (status.runId !== entry.run.runId ||
>       !['completed', 'failed', 'cancelled'].includes(String(status.status))) {
>     throw new Error('Expected a matching terminal run')
>   }
>   const completed = status.status === 'completed'
>   if (completed && typeof status.result !== 'string') throw new Error('Missing result')
>   if (completed) {
>     for (const field of ['wordsSpent', 'creditsSpent', 'meteredChargedCredits']) {
>       const value = status[field]
>       if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
>         throw new Error(`Invalid billing field: ${field}`)
>       }
>     }
>     if (!['prepaid', 'payg', 'subscription'].includes(String(status.billingMode))) {
>       throw new Error('Invalid billing mode')
>     }
>   } else {
>     const error = object(status.error)
>     if (typeof error.code !== 'string' || !error.code || typeof error.message !== 'string' || !error.message) {
>       throw new Error('Invalid terminal error')
>     }
>   }
>   const artifact = {
>     documentId: entry.draft.id,
>     runId: entry.run.runId,
>     apiStatus: status.status,
>     sourceText: entry.draft.text,
>     candidateText: completed ? status.result : null,
>     error: status.error ?? null,
>     billing: completed ? {
>       wordsSpent: status.wordsSpent,
>       creditsSpent: status.creditsSpent,
>       billingMode: status.billingMode,
>       meteredChargedCredits: status.meteredChargedCredits,
>     } : null,
>     editorialReview: {
>       decision: completed ? 'pending' : 'blocked',
>       facts: 'pending', links: 'pending', formatting: 'pending', voice: 'pending',
>       reviewer: null, reviewedAt: null,
>     },
>   }
>   await mkdir(REVIEW_DIR, { recursive: true })
>   const path = `${REVIEW_DIR}/${entry.draft.id}.review.json`
>   const temporary = `${path}.${randomUUID()}.tmp`
>   try {
>     await writeFile(temporary, JSON.stringify(artifact, null, 2), { flag: 'wx' })
>     try {
>       await link(temporary, path) // Publish complete JSON atomically, without replacement.
>     } catch (error) {
>       if (!(error instanceof Error) || !('code' in error) || error.code !== 'EEXIST') throw error
>       const saved = object(JSON.parse(await readFile(path, 'utf8')))
>       if (saved.runId !== entry.run.runId) throw new Error('Use a new document revision ID')
>       if (saved.apiStatus !== status.status && saved.apiStatus !== 'completed') {
>         throw new Error('Terminal state conflict; inspect the saved review')
>       }
>       // A callback or earlier poll already saved it. Keep any human review notes.
>     }
>   } finally {
>     try { await unlink(temporary) } catch (error) {
>       if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error
>     }
>   }
> }
>
> function input(draft: Draft) {
>   const webhookUrl = process.env.STEALTHGPT_WEBHOOK_URL
>   const webhookSecret = process.env.STEALTHGPT_WEBHOOK_SECRET
>   if (webhookUrl && (!webhookSecret || webhookSecret.length < 16 || webhookSecret.length > 255)) {
>     throw new Error('Set a 16–255 character webhook secret')
>   }
>   return {
>     text: draft.text, rephrase: true, model: 'super',
>     qualityMode: 'quality', outputFormat: 'text',
>     ...(webhookUrl ? { webhookUrl, webhookSecret } : {}),
>   }
> }
>
> async function collect(entries: Entry[]) {
>   const pending = new Map(entries.map(entry => [entry.run.runId, entry]))
>   const deadline = Date.now() + WAIT_MS
>   let blocked = false
>   while (pending.size && Date.now() < deadline) {
>     for (const [id, entry] of pending) {
>       const status = await api(entry.run.statusUrl)
>       if (status.runId !== id) throw new Error('Run ID mismatch')
>       if (['completed', 'failed', 'cancelled'].includes(String(status.status))) {
>         await writeReview(entry, status)
>         blocked ||= status.status !== 'completed'
>         pending.delete(id)
>       } else if (!['queued', 'running'].includes(String(status.status))) {
>         throw new Error('Unexpected run status')
>       }
>     }
>     if (pending.size) await sleep(POLL_MS)
>   }
>   if (pending.size) throw new Error('Client deadline reached; resume with the same input and IDs')
>   if (blocked) throw new Error('Inspect blocked review files before submitting more work')
> }
>
> async function main() {
>   const [mode, file] = process.argv.slice(2)
>   if (!['single', 'bulk'].includes(mode ?? '') || !file) {
>     throw new Error('Usage: node review.ts single|bulk review-drafts.json')
>   }
>   const raw: unknown = JSON.parse(await readFile(file, 'utf8'))
>   if (!Array.isArray(raw) || !raw.length) throw new Error('Expected a non-empty draft array')
>   const drafts = raw.map(value => {
>     const draft = object(value)
>     if (typeof draft.id !== 'string' || !/^[A-Za-z0-9_-]{1,80}$/.test(draft.id) ||
>         typeof draft.text !== 'string' || !draft.text.trim()) throw new Error('Invalid draft')
>     return { id: draft.id, text: draft.text }
>   })
>   if (new Set(drafts.map(draft => draft.id)).size !== drafts.length) throw new Error('Duplicate draft IDs')
>   await mkdir(REVIEW_DIR, { recursive: true })
>   const manifestPath = `${REVIEW_DIR}/manifest.json`
>   let manifest: Entry[] = []
>   try { manifest = JSON.parse(await readFile(manifestPath, 'utf8')) } catch (error) {
>     if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error
>   }
>   const requests = new Map(drafts.map(draft => [draft.id, input(draft)]))
>   const hashes = new Map([...requests].map(([id, body]) => [id,
>     createHash('sha256').update(JSON.stringify(body)).digest('hex')]))
>   const known = new Map(manifest.map(entry => [entry.draft.id, entry]))
>   for (const draft of drafts) {
>     const previous = known.get(draft.id)
>     if (previous && (previous.draft.text !== draft.text || previous.requestHash !== hashes.get(draft.id))) {
>       throw new Error('Changed request: use a new revision ID')
>     }
>   }
>   const size = mode === 'bulk' ? MAX_BATCH : 1
>   for (let start = 0; start < drafts.length; start += size) {
>     const selected = drafts.slice(start, start + size)
>     const existing = selected.flatMap(draft => known.has(draft.id) ? [known.get(draft.id)!] : [])
>     if (existing.length) await collect(existing) // Resume saved runs without another POST.
>     const chunk = selected.filter(draft => !known.has(draft.id))
>     if (!chunk.length) continue
>     const created = mode === 'bulk'
>       ? await api('/api/stealthify/runs/bulk', {
>           items: chunk.map(draft => ({ ...requests.get(draft.id), idempotencyKey: draft.id })),
>         })
>       : await api('/api/stealthify/runs', requests.get(chunk[0]!.id), chunk[0]!.id)
>     const refs = mode === 'bulk' ? created.runs : [created]
>     if (!Array.isArray(refs) || refs.length !== chunk.length) throw new Error('Run count mismatch')
>     const entries = chunk.map((draft, index) => ({
>       draft, run: runRef(refs[index]), requestHash: hashes.get(draft.id)!,
>     }))
>     manifest = [...manifest, ...entries]
>     await writeFile(`${manifestPath}.tmp`, JSON.stringify(manifest, null, 2))
>     await rename(`${manifestPath}.tmp`, manifestPath)
>     await collect(entries)
>   }
>   console.log(`Review files saved in ${REVIEW_DIR}; editorial decisions are still pending.`)
> }
>
> if (process.argv[1] && await realpath(process.argv[1]) === fileURLToPath(import.meta.url)) {
>   await main()
> }
> ```
>
> ```bash theme={null}
> node review.ts single review-drafts.json
> ```

> [tab] Python · 3.10+
>
> Save this as `review.py`. It uses only Python's standard library.
>
> ```python review.py theme={null}
> import hashlib
> from http.client import IncompleteRead
> import json
> import os
> from pathlib import Path
> import re
> import tempfile
> import time
> from urllib.error import HTTPError, URLError
> from urllib.parse import urljoin, urlparse
> from urllib.request import Request, urlopen
>
> API = "https://www.stealthgpt.ai"
> REVIEW_DIR = Path(os.environ.get("REVIEW_DIR", "./review-output"))
> MAX_ATTEMPTS = 5
> MAX_BATCH = 100
> MAX_BODY_BYTES = 4_000_000
> POLL_SECONDS = 10
> WAIT_SECONDS = 45 * 60
>
>
> def api(path, body=None, key=None):
>     url = urljoin(API, path)
>     parsed = urlparse(url)
>     prefix = "/api/stealthify/runs"
>     suffix = parsed.path[len(prefix) + 1:]
>     valid_path = (parsed.path.startswith(prefix + "/") and bool(suffix) and "/" not in suffix) if body is None else parsed.path in (prefix, prefix + "/bulk")
>     if f"{parsed.scheme}://{parsed.netloc}" != API or parsed.query or parsed.fragment or not valid_path:
>         raise ValueError("Unexpected API URL")
>     token = os.environ["STEALTHGPT_API_TOKEN"]
>     encoded = None if body is None else json.dumps(body).encode("utf-8")
>     if encoded and len(encoded) > MAX_BODY_BYTES:
>         raise ValueError("Request is too large; use fewer or shorter drafts")
>     headers = {"api-token": token, "Content-Type": "application/json"}
>     if key:
>         headers["idempotency-key"] = key
>     for attempt in range(MAX_ATTEMPTS):
>         request = Request(url, data=encoded, headers=headers, method="GET" if body is None else "POST")
>         status = None
>         retry_after = ""
>         try:
>             try:
>                 with urlopen(request, timeout=30) as response:
>                     raw = response.read()
>             except HTTPError as error:
>                 status = error.code
>                 retry_after = error.headers.get("Retry-After", "")
>                 with error:
>                     raw = error.read()
>         except (URLError, TimeoutError, ConnectionError, IncompleteRead):
>             if attempt == MAX_ATTEMPTS - 1:
>                 raise
>             time.sleep(2 ** attempt)
>             continue
>         # Parse only after transport completes; malformed JSON must still fail.
>         detail = raw.decode("utf-8")
>         if status is None:
>             return json.loads(detail)
>         retryable = status == 429 or status >= 500 or (status == 409 and parsed.path.endswith("/bulk"))
>         if not retryable or attempt == MAX_ATTEMPTS - 1:
>             raise RuntimeError(f"HTTP {status}: {detail}")
>         delay = float(retry_after) if retry_after.isdigit() else min(30, 2 ** attempt)
>         if delay > 60:
>             raise RuntimeError("Retry later with the same input and IDs")
>         time.sleep(delay)
>     raise RuntimeError("Retry limit reached")
>
>
> def write_review(entry, status):
>     if not isinstance(entry["draft"]["id"], str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", entry["draft"]["id"]) or not isinstance(entry["draft"]["text"], str) or not isinstance(entry["run"]["runId"], str) or not entry["run"]["runId"]:
>         raise ValueError("Invalid review document ID or source")
>     if status.get("runId") != entry["run"]["runId"] or status.get("status") not in ("completed", "failed", "cancelled"):
>         raise ValueError("Expected a matching terminal run")
>     completed = status["status"] == "completed"
>     if completed and not isinstance(status.get("result"), str):
>         raise ValueError("Missing result")
>     if completed:
>         for field in ("wordsSpent", "creditsSpent", "meteredChargedCredits"):
>             if type(status.get(field)) is not int or status[field] < 0:
>                 raise ValueError(f"Invalid billing field: {field}")
>         if status.get("billingMode") not in ("prepaid", "payg", "subscription"):
>             raise ValueError("Invalid billing mode")
>     else:
>         error = status.get("error")
>         if not isinstance(error, dict) or not isinstance(error.get("code"), str) or not error["code"] or not isinstance(error.get("message"), str) or not error["message"]:
>             raise ValueError("Invalid terminal error")
>     artifact = {
>         "documentId": entry["draft"]["id"],
>         "runId": entry["run"]["runId"],
>         "apiStatus": status["status"],
>         "sourceText": entry["draft"]["text"],
>         "candidateText": status["result"] if completed else None,
>         "error": status.get("error"),
>         "billing": {key: status.get(key) for key in (
>             "wordsSpent", "creditsSpent", "billingMode", "meteredChargedCredits"
>         )} if completed else None,
>         "editorialReview": {
>             "decision": "pending" if completed else "blocked",
>             "facts": "pending", "links": "pending", "formatting": "pending", "voice": "pending",
>             "reviewer": None, "reviewedAt": None,
>         },
>     }
>     REVIEW_DIR.mkdir(parents=True, exist_ok=True)
>     path = REVIEW_DIR / f"{entry['draft']['id']}.review.json"
>     temporary = None
>     try:
>         with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=REVIEW_DIR,
>                                          prefix=f".{path.name}.", suffix=".tmp", delete=False) as output:
>             temporary = Path(output.name)
>             json.dump(artifact, output, indent=2)
>         try:
>             os.link(temporary, path)  # Publish complete JSON without replacing an existing file.
>         except FileExistsError:
>             saved = json.loads(path.read_text(encoding="utf-8"))
>             if saved["runId"] != entry["run"]["runId"]:
>                 raise ValueError("Use a new document revision ID")
>             if saved["apiStatus"] != status["status"] and saved["apiStatus"] != "completed":
>                 raise ValueError("Terminal state conflict; inspect the saved review")
>             # Keep any human review notes already saved by a callback or earlier poll.
>     finally:
>         if temporary is not None:
>             temporary.unlink(missing_ok=True)
>
>
> def run_input(draft):
>     webhook_url = os.environ.get("STEALTHGPT_WEBHOOK_URL")
>     webhook_secret = os.environ.get("STEALTHGPT_WEBHOOK_SECRET", "")
>     if webhook_url and not 16 <= len(webhook_secret) <= 255:
>         raise ValueError("Set a 16–255 character webhook secret")
>     result = {
>         "text": draft["text"], "rephrase": True, "model": "super",
>         "qualityMode": "quality", "outputFormat": "text",
>     }
>     if webhook_url:
>         result.update(webhookUrl=webhook_url, webhookSecret=webhook_secret)
>     return result
>
>
> def collect(entries):
>     pending = {entry["run"]["runId"]: entry for entry in entries}
>     deadline = time.monotonic() + WAIT_SECONDS
>     blocked = False
>     while pending and time.monotonic() < deadline:
>         for run_id, entry in list(pending.items()):
>             status = api(entry["run"]["statusUrl"])
>             if status.get("runId") != run_id:
>                 raise ValueError("Run ID mismatch")
>             if status.get("status") in ("completed", "failed", "cancelled"):
>                 write_review(entry, status)
>                 blocked = blocked or status["status"] != "completed"
>                 del pending[run_id]
>             elif status.get("status") not in ("queued", "running"):
>                 raise ValueError("Unexpected run status")
>         if pending:
>             time.sleep(POLL_SECONDS)
>     if pending:
>         raise RuntimeError("Client deadline reached; resume with the same input and IDs")
>     if blocked:
>         raise RuntimeError("Inspect blocked review files before submitting more work")
>
>
> def main(mode, file):
>     if mode not in ("single", "bulk"):
>         raise ValueError("Choose single or bulk")
>     drafts = json.loads(Path(file).read_text(encoding="utf-8"))
>     if not isinstance(drafts, list) or not drafts:
>         raise ValueError("Expected a non-empty draft array")
>     for draft in drafts:
>         if not isinstance(draft, dict) or not isinstance(draft.get("id"), str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", draft["id"]) or not isinstance(draft.get("text"), str) or not draft["text"].strip():
>             raise ValueError("Invalid draft")
>     if len({draft["id"] for draft in drafts}) != len(drafts):
>         raise ValueError("Duplicate draft IDs")
>     REVIEW_DIR.mkdir(parents=True, exist_ok=True)
>     manifest_path = REVIEW_DIR / "manifest.json"
>     manifest = json.loads(manifest_path.read_text(encoding="utf-8")) if manifest_path.exists() else []
>     requests = {draft["id"]: run_input(draft) for draft in drafts}
>     hashes = {key: hashlib.sha256(json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() for key, body in requests.items()}
>     known = {entry["draft"]["id"]: entry for entry in manifest}
>     for draft in drafts:
>         previous = known.get(draft["id"])
>         if previous and (previous["draft"]["text"] != draft["text"] or previous.get("requestHash") != hashes[draft["id"]]):
>             raise ValueError("Changed request: use a new revision ID")
>     size = MAX_BATCH if mode == "bulk" else 1
>     for start in range(0, len(drafts), size):
>         selected = drafts[start:start + size]
>         existing = [known[draft["id"]] for draft in selected if draft["id"] in known]
>         if existing:
>             collect(existing)  # Resume saved runs without another POST.
>         chunk = [draft for draft in selected if draft["id"] not in known]
>         if not chunk:
>             continue
>         if mode == "bulk":
>             created = api("/api/stealthify/runs/bulk", {
>                 "items": [dict(requests[draft["id"]], idempotencyKey=draft["id"]) for draft in chunk]
>             })
>             refs = created["runs"]
>         else:
>             refs = [api("/api/stealthify/runs", requests[chunk[0]["id"]], chunk[0]["id"])]
>         if len(refs) != len(chunk) or any(not isinstance(run.get("runId"), str) or not isinstance(run.get("statusUrl"), str) for run in refs):
>             raise ValueError("Invalid create response")
>         entries = [{"draft": draft, "run": run, "requestHash": hashes[draft["id"]]} for draft, run in zip(chunk, refs)]
>         manifest = manifest + entries
>         temporary = manifest_path.with_suffix(".tmp")
>         temporary.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
>         temporary.replace(manifest_path)
>         collect(entries)
>     print(f"Review files saved in {REVIEW_DIR}; editorial decisions are still pending.")
>
>
> if __name__ == "__main__":
>     import sys
>     if len(sys.argv) != 3:
>         raise SystemExit("Usage: python3 review.py single|bulk review-drafts.json")
>     main(sys.argv[1], sys.argv[2])
> ```
>
> ```bash theme={null}
> python3 review.py single review-drafts.json
> ```

The result is one `review-output/<document-id>.review.json` per draft, plus a `manifest.json` mapping your IDs to API runs and a hash of each intended request. On restart, known runs are polled directly without another create call; only drafts absent from the manifest are submitted. A changed draft, model setting or callback configuration under an existing ID is rejected before submission. Run one worker per output directory; use a transactional database or job store when multiple workers share a queue.

The API does not expose a caller-controlled idempotency expiry. Durable run IDs are your resume mechanism, rather than an assumption that a provider key remains valid indefinitely. If a saved run returns `404`, investigate that record; these clients do not silently create a replacement.

### Make review a publishing gate

Open each review file and compare `sourceText` with `candidateText`. Check names, numbers, claims and citations; test links; inspect headings, tables, lists and brand voice in the destination renderer. Then record the reviewer, time and decision in `editorialReview`. A downstream CMS integration should require an approved decision and the reviewed version of the text before creating a publication.

The examples never set `approved`. They publish each completed JSON file through a temporary file and an atomic hard link, so overlapping polls and callbacks see complete reviews and keep existing editorial notes. Use a local filesystem that supports hard links. For a new paid attempt, use a new document revision ID and keep the old record.

## Recipe 2: bulk humanization → collect every result → review

Use the same script and draft format with `bulk`:

```bash TypeScript theme={null}
node review.ts bulk review-drafts.json
```

```bash Python theme={null}
python3 review.py bulk review-drafts.json
```

Each request contains **1–100 items**, with `idempotencyKey` on every item. The response's `runs[i]` corresponds to `items[i]`; the scripts save this mapping and poll each `statusUrl`. They wait until the current batch is terminal before submitting the next one. Failed items are saved with `editorialReview.decision: "blocked"`, and the scripts stop before sending another batch.

* The platform caps the request body at **4.5 MB**. These examples use a conservative 4,000,000-byte client check; long drafts may need fewer than 100 items.
* A different batch submitted while a previous bulk batch is active can return **409**. A resend covering those same active items with unchanged keys and inputs can recover the existing runs.
* `429` or `500` can occur after keyed items were already created. Resend the same complete request; do not generate new keys for a transport retry.
* A failed run remains failed under the same key and request. Inspect its `error.code`; correct the cause and deliberately choose a new revision ID for a new attempt.

See [Bulk Async Runs](https://docs.stealthgpt.ai/api-reference/endpoints/stealthify-runs-bulk.md) for the account batch gate and full error contract.

### Optional: signed webhooks with polling reconciliation

Set these **before** creating runs if you have an HTTPS callback endpoint:

```bash theme={null}
export STEALTHGPT_WEBHOOK_URL='https://your-app.example/webhooks/stealthgpt'
export STEALTHGPT_WEBHOOK_SECRET='a-random-secret-at-least-16-characters'
```

The callback includes the same terminal payload as status polling. Verify HMAC over the **timestamp plus raw body**, confirm the run is in your manifest, then use the same review writer. A duplicate callback and a later poll converge on the same file. Never route a completed callback straight to a public CMS post.

This TypeScript handler uses the `review.ts` functions above. Mount `POST` in your existing backend and give it the same durable review directory and secret as the worker. It performs local persistence only, so it can acknowledge promptly within the API's 10-second delivery timeout. For serverless deployments, replace local files with a shared durable store; an ephemeral filesystem is not a delivery queue.

```typescript webhook.ts theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { object, REVIEW_DIR, writeReview, type Entry } from './review.ts'

const TOLERANCE_SECONDS = 5 * 60

export async function POST(request: Request): Promise<Response> {
  const secret = process.env.STEALTHGPT_WEBHOOK_SECRET
  if (!secret) return new Response('Webhook secret not configured', { status: 503 })
  const rawBody = Buffer.from(await request.arrayBuffer())
  const timestamp = request.headers.get('x-stealthgpt-timestamp') ?? ''
  const signature = request.headers.get('x-stealthgpt-signature') ?? ''
  if (!/^\d+$/.test(timestamp) ||
      Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS ||
      !/^v1=[a-f0-9]{64}$/i.test(signature)) {
    return new Response('Invalid signature', { status: 401 })
  }
  const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest()
  const received = Buffer.from(signature.slice(3), 'hex')
  if (!timingSafeEqual(expected, received)) return new Response('Invalid signature', { status: 401 })
  try {
    const payload = object(JSON.parse(rawBody.toString('utf8')))
    if (request.headers.get('x-stealthgpt-run-id') !== payload.runId ||
        request.headers.get('x-stealthgpt-event') !== `stealthify.run.${payload.status}`) {
      return new Response('Event mismatch', { status: 400 })
    }
    const manifest: Entry[] = JSON.parse(await readFile(`${REVIEW_DIR}/manifest.json`, 'utf8'))
    const entry = manifest.find(item => item.run.runId === payload.runId)
    // A callback can arrive before the worker saves its manifest. Let delivery retry.
    if (!entry) return new Response('Run not recorded yet', { status: 503 })
    await writeReview(entry, payload)
    return new Response(null, { status: 204 })
  } catch {
    return new Response('Reconciliation unavailable', { status: 503 })
  }
}
```

Delivery is best-effort, with up to three attempts for network errors, timeouts, `429` and `5xx`. Other `4xx` responses stop delivery retries. Keep polling or schedule status reconciliation from the saved manifest; a missing callback does not mean the run failed. These scripts continue polling even when webhooks are enabled, making callback delivery an acceleration path rather than the only record of completion.

## Billing, model controls and recovery

Humanization bills **input plus output billable words**, rounded up. Standard and Lite use a **1×** multiplier; Super uses **2.5×**. For example, 1,000 input words plus 900 output words is 1,900 plan words for Standard/Lite or 4,750 for Super. The examples store the API's actual `wordsSpent`, `creditsSpent`, `billingMode` and `meteredChargedCredits` fields; use those values for reconciliation rather than estimating final output length. [Preserved spans](https://docs.stealthgpt.ai/api-reference/text-preservation.md) can affect billable word counts.

Creating runs checks estimated billing access and can reserve prepaid credits. Each run is billed when it finishes. A bulk estimate is checked as a whole, and a later billing problem can still end an individual run as failed. REST calls use the API balance; MCP can follow a different account billing setting. See [API pricing](https://docs.stealthgpt.ai/api-reference/pricing.md) for current plan economics.

`qualityMode: "quality"` is a model control, not editorial approval. Standard uses its quality-check pipeline; Super's quality mode enables em-dash cleanup, and Lite uses its lightweight path. Choose the [model](https://docs.stealthgpt.ai/api-reference/models.md) for your workload and review the resulting draft. For format-sensitive content, use [Text Preservation](https://docs.stealthgpt.ai/api-reference/text-preservation.md) and verify the reconstructed document; selecting Markdown alone does not establish that every table, link or layout survives.

| Result | Action |
| - | - |
| HTTP `400` / `401` | Fix the body, IDs or API token. These scripts stop without retrying. |
| HTTP `402` | Check your API balance and billing setup; do not loop on payment errors. |
| HTTP `409` in bulk | Finish/reconcile the active batch; retry the same complete keyed batch if recovering a submission. |
| HTTP `413` or local body-size error | Send fewer or shorter items. |
| HTTP `429`, `5xx`, network timeout | Retry with unchanged input and keys; attempts are bounded. |
| Terminal `failed` / `cancelled` | Save the error for review, resolve the cause, and use a new revision ID for an intentional new run. |
| Client wait deadline | The server run may still be active. Resume with unchanged input and IDs to retrieve it. |

### Using n8n

You can build the same review handoff with the [n8n integration](https://docs.stealthgpt.ai/api-reference/integrations/n8n.md) or HTTP Request nodes. Check your installed node version before importing a workflow: the published [`n8n-nodes-stealthgpt` 0.1.2](https://www.npmjs.com/package/n8n-nodes-stealthgpt/v/0.1.2) schema exposes `heavy` and `lite` for humanization (`heavy` maps to Standard), and has no bulk operation. Use the REST contract directly for an explicit Super selection or bulk submission. Store API tokens in n8n credentials, and send results to a draft/review destination before any publish node.

## Related references

* [Async create, status and webhook contract](https://docs.stealthgpt.ai/api-reference/endpoints/stealthify-runs.md)
* [Bulk limits and idempotency](https://docs.stealthgpt.ai/api-reference/endpoints/stealthify-runs-bulk.md)
* [Workflow patterns](https://docs.stealthgpt.ai/api-reference/workflow-patterns.md)
* [Codex MCP setup](https://docs.stealthgpt.ai/api-reference/integrations/codex.md)

These examples were checked against the API source and exercised with local fixtures and mocked responses. No paid API calls or CMS publications are part of the example validation.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.