---
title: "API Documentation"
description: "HumanizeThisAI developer API reference: auth, scopes, limits, humanize, detect, tones, idempotency, errors, rate limits, and working code examples."
url: "https://humanizethisai.com/docs/api"
lastModified: "2026-09-12"
---

# API Documentation

> HumanizeThisAI developer API reference: auth, scopes, limits, humanize, detect, tones, idempotency, errors, rate limits, and working code examples.

**Base URL:** `https://api.humanizethisai.com` · HTTPS only · JSON + SSE

This page is also available as plain markdown:
- `https://humanizethisai.com/docs/api.md`
- `Accept: text/markdown` on `https://humanizethisai.com/docs/api`

## Table of contents

- [Quick Start](#quick-start)
- [Authentication](#authentication)
- [Limits & Billing](#limits--billing)
- [Endpoints](#endpoints)
- [Idempotency](#idempotency)
- [Error Codes](#error-codes)
- [Rate Limits](#rate-limits)
- [Code Examples](#code-examples)

## Quick Start

Three steps to your first humanized output. Text must be at least **20 words** or the API returns `400 INVALID_TEXT`.

1. **Get your API key** — Subscribe to an API plan, open the Developer dashboard, and create a key (`htai_live_…`). Copy it once — we only store a hash.
2. **Send a request** — POST to `/v1/humanize` with Authorization, Content-Type, optional Idempotency-Key, and a JSON body.
3. **Handle the response** — On 200, read `humanized_text`. On error, match the `code` field in the problem+json body — never parse `detail`.

### Request

```bash
curl -X POST https://api.humanizethisai.com/v1/humanize \
  -H "Authorization: Bearer htai_live_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
  -d '{
    "text": "Artificial intelligence has revolutionized the way we interact with technology. Machine learning models can now generate coherent text that closely resembles human writing across many domains.",
    "mode": "standard",
    "tone": "casual"
  }'
```

### Response

```json
{
  "object": "humanization",
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "humanized_text": "AI has completely changed how we deal with tech day to day. Models can now write text that feels a lot like something a person would produce, across plenty of different fields.",
  "input_words": 26,
  "output_words": 36,
  "mode": "standard",
  "tone": "casual",
  "language": "en",
  "best_of_used": 1,
  "is_custom_tone": false,
  "words_charged": 26,
  "words_remaining": 999974
}
```

## Authentication

Every authenticated endpoint requires a Bearer token in the `Authorization` header. `GET /v1/health` is the only public route.

```http
Authorization: Bearer htai_live_...
```

### Request headers

| Header | Required | Description |
|--------|----------|-------------|
| `Authorization` | Yes* | Bearer `htai_live_…`. *Not required on `GET /v1/health`. |
| `Content-Type` | Yes on POST/PUT | `application/json` |
| `Idempotency-Key` | Optional | humanize + detect only. 1–128 chars of A–Z a–z 0–9 `_` `-` `:` `.`. Becomes the response `id` when set. |
| `Accept` | Optional | Defaults to `application/json`. Streaming is controlled by body `stream: true` (response is `text/event-stream`). |

### Scopes

Each key carries a set of permissions. Missing a required scope returns `403 SCOPE_DENIED`. Configure scopes when you create or edit a key in the Developer dashboard.

| Scope | Grants |
|-------|--------|
| `humanize` | `POST /v1/humanize`. Using a custom tone here only needs this scope — not `tones`. |
| `detect` | `POST /v1/detect` |
| `tones` | `POST/GET/PUT/DELETE /v1/tones`. Create and manage custom tones. |

### Key management

- **Generation:** Create keys from the Developer dashboard. Every live key starts with `htai_live_`.
- **Security:** The full key is shown once at creation. We store a SHA-256 hash — even we cannot recover the original. Treat it like a password.
- **Rotation:** Rotating a key puts the old one in a grace period before revocation so you can roll clients without downtime.
- **Limits:** Growth plans get 5 keys, Scale gets 15. Optional IP allowlists return `403 IP_NOT_ALLOWED` when the caller is outside the list.

## Limits & Billing

Hard limits enforced on every request. Hitting one returns a stable error `code` — see Error Codes. JSON request bodies are capped at **128 KiB**; over → `413 PAYLOAD_TOO_LARGE`.

### Word count

- **Minimum:** 20 words on `/v1/humanize` and `/v1/detect`. Shorter text → `400 INVALID_TEXT`.
- **Maximum per request:** plan cap (5,000 Growth / 10,000 Scale). Over → `400 TEXT_TOO_LONG`.

### How you are charged

- `words_charged = input_words × multiplier`
- Charging is input-only — `output_words` never changes the bill. Rewrite length can vary; there is no guaranteed length band.
- Failed generation refunds the reservation (`refunded: true` on stream errors).
- Tone CRUD never consumes words (`words_charged: 0`).
- Balance too low for the charge → `402 INSUFFICIENT_WORDS` (not 429).

### Multiplier rules

- Plan gate first: `best_of` must be an integer 1–5. Values above 3 require Scale → `403 BEST_OF_NOT_AVAILABLE`. This check runs for every mode, including ultra.
- Default multiplier is `best_of` (default 1) for standard, academic, and enhanced.
- `mode: "ultra"` always runs a fixed 3-candidate self-audit pipeline and always bills **3×**. An accepted `best_of` does not change the ultra multiplier; the plan gate above still applies.
- `mode: "aggressive"` defaults to **3×** when `best_of` is omitted; an explicit value overrides it.

### Plan table

| Plan | plan.key | Words / mo | Max / request | RPM | Concurrent | Keys | Custom tones | best_of max |
|------|----------|------------|---------------|-----|------------|------|--------------|-------------|
| Growth | `api_growth` | 1,000,000 | 5,000 | 60 | 10 | 5 | 10 | 3 |
| Scale | `api_scale` | 3,000,000 | 10,000 | 200 | 30 | 15 | 50 | 5 |

Both plans include all humanize modes, streaming, and detection. Read live remaining balance from `GET /v1/usage` or any success response's `words_remaining`.

## Endpoints

| Method | Path | Auth | Scope | Description |
|--------|------|------|-------|-------------|
| POST | `/v1/humanize` | Bearer | `humanize` | Humanize text (JSON or SSE) |
| POST | `/v1/detect` | Bearer | `detect` | AI detection scoring |
| GET | `/v1/usage` | Bearer | — | Plan, balance, key info |
| HEAD | `/v1/usage` | Bearer | — | Same auth as GET; empty body |
| GET | `/v1/health` | None | — | Health check (200/503) |
| HEAD | `/v1/health` | None | — | Same as GET; empty body |
| POST | `/v1/tones` | Bearer | `tones` | Create custom tone |
| GET | `/v1/tones` | Bearer | `tones` | List custom tones |
| GET | `/v1/tones/:id` | Bearer | `tones` | Get one tone |
| PUT | `/v1/tones/:id` | Bearer | `tones` | Update tone fields |
| DELETE | `/v1/tones/:id` | Bearer | `tones` | Delete tone |

### POST https://api.humanizethisai.com/v1/humanize

Primary endpoint. Rewrites AI-generated text. Default response is JSON; set `stream: true` for SSE. Typical completion is a few seconds; use `stream: true` for a progressive UI. Latency is reported as the `X-Latency-Ms` response header and as `latency_ms` on the SSE `done` event.

Rewrite length can vary from the input. `words_charged` is always `input_words × multiplier` — never based on `output_words`.

#### Request body

```json
{
  "text": "string (required) — AI text to humanize, min 20 words",
  "mode": "standard | academic | enhanced | ultra | aggressive",
  "tone": "casual | formal | professional | academic | creative | <custom name|uuid>",
  "readability": "high-school | college | graduate | doctorate",
  "freeze_words": ["BrandName", "TechnicalTerm"],
  "best_of": 1,
  "language": "en",
  "stream": false
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `text` | string | Yes | AI-generated text to humanize. Minimum 20 words. Maximum = your plan's max_words_per_request (5,000 Growth / 10,000 Scale). |
| `mode` | string | No | Default: standard. One of: standard, academic, enhanced, ultra, aggressive |
| `tone` | string | No | Default: casual. Built-in: casual, formal, professional, academic, creative. Custom: exact name or UUID from GET /v1/tones. Malformed tone → 400 INVALID_TONE; unknown custom name/UUID → 404 TONE_NOT_FOUND. |
| `readability` | string | No | Writing level. One of: high-school, college, graduate, doctorate. Default: college. Unrecognized values fall back to college. |
| `freeze_words` | string[] | No | Words/phrases preserved unchanged. Max 100 entries, 100 characters each — overflow returns 400 INVALID_FREEZE_WORDS. HTML is stripped; blank entries are dropped. |
| `best_of` | integer | No | 1–5. Default 1. Values above 3 require Scale (403 BEST_OF_NOT_AVAILABLE) on every mode including ultra. For standard/academic/enhanced multiplies charge by N. Ultra always bills 3× via its fixed pipeline; aggressive defaults to 3× when omitted. |
| `language` | string | No | Output language (ISO 639-1): en, es, fr, de, it, pt, ru, zh, ja, ko, tr, ar. Default: en. Unrecognized → en. |
| `stream` | boolean | No | Enable SSE streaming. Default: false. Response Content-Type becomes text/event-stream. |

**Billing & best_of:** Charged as `words_charged = input_words × multiplier`. See Limits & Billing for ultra / aggressive floors and Scale gating. Output length never affects the charge.

#### Response (200 OK)

```json
{
  "object": "humanization",
  "id": "8f2a1c6e-3b4d-4f0a-9c12-7e5d8a1b2c3d",
  "humanized_text": "Your humanized output...",
  "input_words": 26,
  "output_words": 36,
  "mode": "standard",
  "tone": "casual",
  "language": "en",
  "best_of_used": 1,
  "is_custom_tone": false,
  "words_charged": 26,
  "words_remaining": 999974
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `object` | string | — | Always "humanization" |
| `id` | string | — | Canonical request id. Equals X-Request-Id. Equals your Idempotency-Key when one was sent. |
| `humanized_text` | string | — | Rewritten output |
| `input_words` | integer | — | Words counted in the request text |
| `output_words` | integer | — | Words in humanized_text |
| `mode` | string | — | Mode that ran |
| `tone` | string | — | Echo of the tone you sent (built-in name, custom name, or UUID). |
| `language` | string | — | Resolved output language |
| `best_of_used` | integer | — | Effective fan-out after mode floors |
| `is_custom_tone` | boolean | — | true when a custom tone was applied |
| `tone_id` | string | — | Custom tone UUID. Present only when is_custom_tone is true. |
| `tone_name` | string | — | Custom tone name. Present only when is_custom_tone is true. |
| `words_charged` | integer | — | input_words × multiplier |
| `words_remaining` | integer | — | Balance after this charge |
| `credits_low` | boolean | — | JSON success: included only when true after reservation. SSE done: always present as a boolean (true or false). |
| `replayed` | boolean | — | Present only on idempotent replays (also X-Idempotent-Replay: true) |

#### Streaming response (SSE events)

```text
event: meta
data: {"id":"8f2a1c6e-3b4d-4f0a-9c12-7e5d8a1b2c3d","mode":"standard","tone":"casual","language":"en","input_words":26}

event: chunk
data: {"text":"AI has completely "}

event: chunk
data: {"text":"changed how we "}

event: chunk
data: {"text":"deal with tech day to day."}

event: done
data: {"object":"humanization","id":"8f2a1c6e-3b4d-4f0a-9c12-7e5d8a1b2c3d","input_words":26,"output_words":36,"words_charged":26,"words_remaining":999974,"credits_low":false,"language":"en","best_of_used":1,"is_custom_tone":false,"latency_ms":1342}

# If generation fails mid-stream, a single error event is emitted instead of done:
event: error
data: {"code":"AI_GENERATION_FAILED","message":"Failed to generate humanized text. You were not charged.","refunded":true}
```

- `meta` — Request metadata: `id`, `mode`, `tone`, `language`, `input_words`.
- `chunk` — Partial text: `{ text }`. Repeats until complete.
- `done` — Terminal success with object, id, word counts, words_charged / words_remaining, best_of_used, is_custom_tone, latency_ms, and `credits_low` as a boolean that is always present on SSE (`true` or `false`).
- `error` — Terminal failure: `{ code, message, refunded }`. Emitted instead of done. `refunded: true` means you were not charged.
- Replays of a prior streamed request always return **JSON**, not SSE — see Idempotency.

### POST https://api.humanizethisai.com/v1/detect

AI detection scoring. Returns AI/human probabilities, a verdict, and signal details. Charged on `input_words` (multiplier 1). Scope: `detect`. Available on Growth & Scale.

#### Request body

```json
{
  "text": "string (required) — text to score, min 20 words"
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `text` | string | Yes | Text to analyze. Minimum 20 words. Same max-per-request cap as humanize. |

#### Response (200 OK)

```json
{
  "object": "detection",
  "id": "c4d5e6f7-8a9b-4c0d-1e2f-3a4b5c6d7e8f",
  "ai_score": 72,
  "human_score": 28,
  "confidence": "high",
  "verdict": "likely_ai",
  "label": "Likely AI",
  "signals": [
    { "signal": "Low lexical variance", "severity": "high", "detail": "Repetitive phrasing across sentences" }
  ],
  "sentences": [],
  "summary": "Text shows strong markers of AI generation.",
  "input_words": 26,
  "words_charged": 26,
  "words_remaining": 999948
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `object` | string | — | Always "detection" |
| `id` | string | — | Canonical request id (= X-Request-Id / Idempotency-Key) |
| `ai_score` | integer | — | 0–100 estimated AI probability |
| `human_score` | integer | — | 0–100 estimated human probability |
| `confidence` | string | — | Model confidence band (e.g. high, medium, low) |
| `verdict` | string | — | Machine-stable label (e.g. likely_ai) |
| `label` | string | — | Human-readable verdict |
| `signals` | array | — | [{ signal, severity, detail }] supporting evidence |
| `sentences` | array | — | Optional per-sentence breakdown when available |
| `summary` | string | — | Short natural-language summary |
| `input_words` | integer | — | Words counted in the request |
| `words_charged` | integer | — | Usually equals input_words |
| `words_remaining` | integer | — | Balance after this charge |

### GET / HEAD https://api.humanizethisai.com/v1/usage

Current plan, period usage, optional daily stats, and the calling key's id / name / scopes. No words charged. `HEAD` uses the same auth and returns the same status with an empty body. Use this to drive dashboards and pre-flight checks. If the stats RPC fails, `stats` is `null` and `degraded: true` is set — plan/usage/key still return.

#### Response (200 OK)

```json
{
  "plan": {
    "key": "api_growth",
    "slug": "growth",
    "name": "Growth",
    "words_per_month": 1000000,
    "max_words_per_request": 5000,
    "requests_per_minute": 60,
    "allowed_modes": ["standard", "academic", "enhanced", "ultra", "aggressive"],
    "streaming_enabled": true,
    "detection_enabled": true
  },
  "usage": {
    "words_used": 42150,
    "words_remaining": 957850,
    "words_limit": 1000000,
    "topup_words_remaining": 0,
    "requests_this_period": 1203,
    "period_end": "2026-05-01T00:00:00Z"
  },
  "stats": {
    "summary": {
      "total_requests": 1203,
      "total_words_charged": 42150,
      "total_input_words": 41000,
      "total_output_words": 40200,
      "avg_latency_ms": 1380,
      "error_count": 4,
      "success_rate": 99.67
    },
    "daily": [
      { "date": "2026-04-07", "requests": 47, "words_charged": 1650, "errors": 0 }
    ]
  },
  "key": {
    "id": "0b9c8d7e-6f5a-4b3c-2d1e-0f9a8b7c6d5e",
    "name": "Production Key",
    "scopes": ["humanize", "detect", "tones"]
  }
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `plan.key` | string | — | Wire plan id: "api_growth" or "api_scale". |
| `plan.slug` | string | — | Friendly short id without the api_ prefix (growth / scale). |
| `plan.name` | string | — | Display name (Growth / Scale) |
| `plan.max_words_per_request` | integer | — | Hard cap enforced on humanize/detect |
| `usage.words_remaining` | integer | — | Monthly remaining + top-up remaining |
| `usage.topup_words_remaining` | integer | — | Purchased top-up balance still available |
| `key.scopes` | string[] | — | Scopes on this API key |

### GET / HEAD https://api.humanizethisai.com/v1/health

Public health check. No authentication. Per-IP in-memory limit of 60 requests/minute (not your plan RPM). Returns `200` when database and AI are both healthy; otherwise `503` with `status: "degraded"` or `"unhealthy"`. Over limit → `429` `application/problem+json` with `code: RATE_LIMITED` and `Retry-After`. `HEAD` returns the same status with an empty body.

#### Response

```json
{
  "status": "healthy",
  "timestamp": "2026-04-07T14:30:00Z",
  "version": "1.0.0"
}
# status is one of: healthy | degraded | unhealthy
# HTTP 200 when all services healthy; HTTP 503 otherwise
# optional "services" object only when an admin secret header is present
```

### Custom tones

Create reusable rewrite voices, then pass the tone **name or UUID** as `tone` on `/v1/humanize`. Listing and writing tones requires the `tones` scope. Using a custom tone on humanize only needs `humanize`. Write ops (POST/PUT/DELETE) are capped at 10 requests/minute. These calls never consume words — `words_charged` is always 0. Caps: 10 tones on Growth, 50 on Scale. Path `:id` must be a UUID → otherwise `400 INVALID_ID`. PUT with no updatable fields → `400 NO_FIELDS`. On every tones response the top-level `id` is the request correlation id (same as `X-Request-Id`). The tone UUID is always `tone.id` — never use the envelope id as the tone id for GET/PUT/DELETE.

| Method | Path | Description |
|--------|------|-------------|
| POST | `/v1/tones` | Create a custom tone |
| GET | `/v1/tones` | List your custom tones |
| GET | `/v1/tones/:id` | Fetch one tone by UUID |
| PUT | `/v1/tones/:id` | Update a tone (any subset of fields) |
| DELETE | `/v1/tones/:id` | Delete a tone |

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes on create | 1–50 characters. Unique per account (case-insensitive). Built-in tone names are reserved → 400 TONE_NAME_RESERVED. Duplicates → 409 TONE_NAME_CONFLICT. |
| `instructions` | string | Yes on create | 1–500 characters. How the model should rewrite the text. |
| `description` | string | No | Up to 500 characters. Optional human note. |
| `example` | string | No | Up to 500 characters. Optional sample of the target voice. |

#### Create — Request

```bash
curl -X POST https://api.humanizethisai.com/v1/tones \
  -H "Authorization: Bearer htai_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Newsletter voice",
    "instructions": "Warm, concise, second-person. Short sentences.",
    "description": "Used for our weekly digest",
    "example": "Hey there — here is what you missed this week."
  }'
```

#### Create — Response (200 OK)

```json
{
  "object": "tone",
  "id": "0b9c8d7e-6f5a-4b3c-2d1e-0f9a8b7c6d5e",
  "tone": {
    "id": "9d7c1b2a-4e5f-4a6b-8c9d-0e1f2a3b4c5d",
    "name": "Newsletter voice",
    "instructions": "Warm, concise, second-person. Short sentences.",
    "description": "Used for our weekly digest",
    "example": "Hey there — here is what you missed this week.",
    "created_at": "2026-04-07T14:30:00Z",
    "updated_at": "2026-04-07T14:30:00Z"
  },
  "words_charged": 0,
  "words_remaining": 957850
}
```

#### List — Response (200 OK)

```json
{
  "object": "list",
  "id": "0b9c8d7e-6f5a-4b3c-2d1e-0f9a8b7c6d5e",
  "tones": [
    {
      "id": "9d7c1b2a-4e5f-4a6b-8c9d-0e1f2a3b4c5d",
      "name": "Newsletter voice",
      "instructions": "Warm, concise, second-person. Short sentences.",
      "description": "Used for our weekly digest",
      "example": "Hey there — here is what you missed this week.",
      "created_at": "2026-04-07T14:30:00Z",
      "updated_at": "2026-04-07T14:30:00Z"
    }
  ],
  "words_charged": 0,
  "words_remaining": 957850
}
```

#### Get one — Response (200 OK)

```json
{
  "object": "tone",
  "id": "0b9c8d7e-6f5a-4b3c-2d1e-0f9a8b7c6d5e",
  "tone": {
    "id": "9d7c1b2a-4e5f-4a6b-8c9d-0e1f2a3b4c5d",
    "name": "Newsletter voice",
    "instructions": "Warm, concise, second-person. Short sentences.",
    "description": "Used for our weekly digest",
    "example": "Hey there — here is what you missed this week.",
    "created_at": "2026-04-07T14:30:00Z",
    "updated_at": "2026-04-07T14:30:00Z"
  },
  "words_charged": 0,
  "words_remaining": 957850
}
```

#### Update — Request

```bash
curl -X PUT https://api.humanizethisai.com/v1/tones/9d7c1b2a-4e5f-4a6b-8c9d-0e1f2a3b4c5d \
  -H "Authorization: Bearer htai_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Updated note for the weekly digest voice"
  }'
```

#### Update — Response (200 OK)

```json
{
  "object": "tone",
  "id": "0b9c8d7e-6f5a-4b3c-2d1e-0f9a8b7c6d5e",
  "tone": {
    "id": "9d7c1b2a-4e5f-4a6b-8c9d-0e1f2a3b4c5d",
    "name": "Newsletter voice",
    "instructions": "Warm, concise, second-person. Short sentences.",
    "description": "Updated note for the weekly digest voice",
    "example": "Hey there — here is what you missed this week.",
    "created_at": "2026-04-07T14:30:00Z",
    "updated_at": "2026-04-07T15:12:00Z"
  },
  "words_charged": 0,
  "words_remaining": 957850
}
```

#### Delete — Response (200 OK)

```json
{
  "object": "tone",
  "id": "0b9c8d7e-6f5a-4b3c-2d1e-0f9a8b7c6d5e",
  "deleted": true,
  "words_charged": 0,
  "words_remaining": 957850
}
```

#### Use on humanize

```bash
curl -X POST https://api.humanizethisai.com/v1/humanize \
  -H "Authorization: Bearer htai_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "text": "Artificial intelligence has revolutionized the way we interact with technology. Machine learning models can now generate coherent text that closely resembles human writing across many domains.",
    "mode": "standard",
    "tone": "Newsletter voice"
  }'
# tone may also be the UUID from GET /v1/tones.
# Success sets is_custom_tone: true and adds tone_id + tone_name. tone echoes what you sent.
```

## Idempotency

`/v1/humanize` and `/v1/detect` accept an optional `Idempotency-Key` request header. Send the same key to safely retry after a timeout or flaky network without being charged twice. The key becomes the response `id` and the `X-Request-Id` header.

- **First use** — the request is processed normally and the result is stored against the key for that API key + endpoint.
- **Replay** — reusing the same key with the **same API key and endpoint** returns the stored result without re-running or re-charging. The response carries `X-Idempotent-Replay: true` and `replayed: true`. Replays are always JSON, even if the original used `stream: true`. Prefer a new key for every distinct job.
- **Conflict** — the same key string used under a different API key, user, or endpoint returns `409 IDEMPOTENCY_KEY_CONFLICT`. Mint a new key for every distinct job. UUIDs are fine.
- **Malformed** — a key outside `1–128` characters of `A-Z a-z 0-9 _ - : .` returns `400 INVALID_IDEMPOTENCY_KEY`. It is never silently replaced.
- **Absent** — without the header the request is not deduplicated; each call is independent and gets a server-generated id.

```bash
# Retry the exact same request safely — reuse the key.
curl -X POST https://api.humanizethisai.com/v1/humanize \
  -H "Authorization: Bearer htai_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4815-humanize-01" \
  -d '{
    "text": "Artificial intelligence has revolutionized the way we interact with technology. Machine learning models can now generate coherent text that closely resembles human writing across many domains.",
    "mode": "standard",
    "tone": "casual"
  }'

# Same key again → stored result, no second charge:
HTTP/1.1 200 OK
X-Idempotent-Replay: true
X-Request-Id: order-4815-humanize-01
Content-Type: application/json

# Body includes "replayed": true and the original humanized_text.

# New job → new key (UUID recommended):
# Idempotency-Key: $(uuidgen)
```

## Error Codes

Every error is an [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807) `application/problem+json` document. Match on the `code` field (stable UPPER_SNAKE) or the full `type` URL — never parse the human-readable `detail`. `request_id` equals the `X-Request-Id` response header. Unknown routes and wrong methods on `/v1/*` also return problem+json. Some codes add structured extension members — `INSUFFICIENT_WORDS` carries `words_required` and `words_remaining`. Each code has a stable doc page at [/errors](https://humanizethisai.com/errors).

### Client handling recipe

1. Read `code` (and HTTP status).
2. `429 RATE_LIMITED` → sleep `Retry-After` seconds, then retry the same request (same Idempotency-Key is safe). `429 CONCURRENT_LIMIT` → wait for an in-flight request to finish, then retry.
3. `402 INSUFFICIENT_WORDS` → top up or upgrade; do not retry until balance covers `words_required`. Empty balance is never 429.
4. `409 IDEMPOTENCY_KEY_CONFLICT` / `RETRY_REQUIRED` → mint a new Idempotency-Key.
5. `5xx` / network timeout → retry with the **same** Idempotency-Key.
6. Client disconnect / cancel: `/v1/detect` → `499 CLIENT_CANCELLED` (not charged / refunded). `/v1/humanize` cancels are returned as `500 AI_GENERATION_FAILED` with a refund when a reservation was taken — do not expect 499 on humanize.
7. Other AI failures: `/v1/humanize` timeouts and provider failures → `500 AI_GENERATION_FAILED`; `/v1/detect` non-cancel AI failures → `500 DETECTION_FAILED`.
8. `4xx` validation errors → fix the payload; do not blind-retry.

### Example

```http
HTTP/1.1 402 Payment Required
Content-Type: application/problem+json

{
  "type": "https://humanizethisai.com/errors/insufficient-words",
  "title": "Insufficient word balance",
  "status": 402,
  "detail": "Insufficient word balance. Need 600, have 50. This request uses mode=\"ultra\", which multiplies word cost by 3. Purchase top-ups or upgrade your plan.",
  "instance": "/v1/humanize",
  "code": "INSUFFICIENT_WORDS",
  "request_id": "8f2a1c6e-3b4d-4f0a-9c12-7e5d8a1b2c3d",
  "words_required": 600,
  "words_remaining": 50
}
```

### Full code catalog

| Code | HTTP | Description |
|------|------|-------------|
| [`MISSING_API_KEY`](https://humanizethisai.com/errors/missing-api-key) | 401 | No Authorization header provided |
| [`INVALID_API_KEY_FORMAT`](https://humanizethisai.com/errors/invalid-api-key-format) | 401 | Key doesn't start with htai_live_ |
| [`INVALID_KEY_FORMAT`](https://humanizethisai.com/errors/invalid-key-format) | 401 | Alias of INVALID_API_KEY_FORMAT — malformed key shape (hash length / prefix) from key validation RPC |
| [`INVALID_KEY`](https://humanizethisai.com/errors/invalid-key) | 401 | Key not found or has been revoked |
| [`KEY_EXPIRED`](https://humanizethisai.com/errors/key-expired) | 401 | Key has expired (rotated) |
| [`UNKNOWN_ERROR`](https://humanizethisai.com/errors/unknown-error) | 401 | Rare auth-path failure when key validation did not return a more specific code. Retry once; if it persists, rotate the key or contact support. |
| [`UNAUTHORIZED`](https://humanizethisai.com/errors/unauthorized) | 403 | Caller is not authorized for this operation |
| [`NO_ACTIVE_SUBSCRIPTION`](https://humanizethisai.com/errors/no-active-subscription) | 403 | No active API plan on the account (gateway auth path) |
| [`NO_SUBSCRIPTION`](https://humanizethisai.com/errors/no-subscription) | 403 | No developer-surface subscription row for this user (billing reservation path; distinct from NO_ACTIVE_SUBSCRIPTION) |
| [`PLAN_NOT_FOUND`](https://humanizethisai.com/errors/plan-not-found) | 403 | Subscription plan_key has no matching plan_configs row |
| [`BANNED`](https://humanizethisai.com/errors/banned) | 403 | Account is suspended |
| [`RISK_BLOCKED`](https://humanizethisai.com/errors/risk-blocked) | 403 | Account restricted due to unusual activity |
| [`TURNSTILE_REQUIRED`](https://humanizethisai.com/errors/turnstile-required) | 403 | Additional verification required before the request can proceed |
| [`SCOPE_DENIED`](https://humanizethisai.com/errors/scope-denied) | 403 | API key missing a required permission (humanize, detect, or tones). Enable it under Permissions or create a new key. |
| [`MODE_NOT_AVAILABLE`](https://humanizethisai.com/errors/mode-not-available) | 403 | Requested mode not included in current plan |
| [`ENDPOINT_NOT_AVAILABLE`](https://humanizethisai.com/errors/endpoint-not-available) | 403 | Endpoint not included in current plan |
| [`STREAMING_NOT_AVAILABLE`](https://humanizethisai.com/errors/streaming-not-available) | 403 | SSE streaming not included in current plan |
| [`DETECTION_NOT_AVAILABLE`](https://humanizethisai.com/errors/detection-not-available) | 403 | AI detection not included in current plan |
| [`IP_NOT_ALLOWED`](https://humanizethisai.com/errors/ip-not-allowed) | 403 | Request IP not in key's allowlist |
| [`RATE_LIMITED`](https://humanizethisai.com/errors/rate-limited) | 429 | Rate limit exceeded — respect Retry-After and retry |
| [`CONCURRENT_LIMIT`](https://humanizethisai.com/errors/concurrent-limit) | 429 | Too many in-flight humanize/detect requests for your plan concurrent cap. Wait for one to finish, then retry. |
| [`MISSING_TEXT`](https://humanizethisai.com/errors/missing-text) | 400 | No text field in request body |
| [`INVALID_TEXT`](https://humanizethisai.com/errors/invalid-text) | 400 | Text failed validation: under 20 words, empty after normalize, disallowed characters, or blocked injection/code patterns |
| [`INVALID_JSON`](https://humanizethisai.com/errors/invalid-json) | 400 | Request body is not valid JSON |
| [`TEXT_TOO_LONG`](https://humanizethisai.com/errors/text-too-long) | 400 | Text exceeds plan's maximum words per request (see GET /v1/usage → plan.max_words_per_request) |
| [`INVALID_MODE`](https://humanizethisai.com/errors/invalid-mode) | 400 | Unknown humanization mode |
| [`INVALID_TONE`](https://humanizethisai.com/errors/invalid-tone) | 400 | Tone is malformed. Use a built-in name, the exact custom tone name, or the tone UUID. |
| [`INVALID_BEST_OF`](https://humanizethisai.com/errors/invalid-best-of) | 400 | best_of is not an integer between 1 and 5 |
| [`INVALID_ID`](https://humanizethisai.com/errors/invalid-id) | 400 | Tone id path parameter must be a UUID |
| [`NO_FIELDS`](https://humanizethisai.com/errors/no-fields) | 400 | Tone PUT body has no updatable fields (name and/or instructions required) |
| [`INVALID_WORD_COUNT`](https://humanizethisai.com/errors/invalid-word-count) | 400 | Word count argument to billing reservation is missing or ≤ 0 |
| [`INVALID_MULTIPLIER`](https://humanizethisai.com/errors/invalid-multiplier) | 400 | Cost multiplier argument to billing reservation is missing or < 1 |
| [`BEST_OF_NOT_AVAILABLE`](https://humanizethisai.com/errors/best-of-not-available) | 403 | best_of above 3 requires the Scale plan |
| [`LEGACY_PROMPT_REMOVED`](https://humanizethisai.com/errors/legacy-prompt-removed) | 400 | Legacy "prompt" field is no longer supported — use "text" |
| [`INVALID_NAME`](https://humanizethisai.com/errors/invalid-name) | 400 | Custom tone name missing or not 1–50 characters after sanitization |
| [`INVALID_INSTRUCTIONS`](https://humanizethisai.com/errors/invalid-instructions) | 400 | Custom tone instructions missing or not 1–500 characters after sanitization |
| [`INJECTION_DETECTED`](https://humanizethisai.com/errors/injection-detected) | 400 | Custom tone field contains blocked injection/code patterns |
| [`INVALID_IDEMPOTENCY_KEY`](https://humanizethisai.com/errors/invalid-idempotency-key) | 400 | Idempotency-Key header is malformed (must be 1–128 chars of A-Z a-z 0-9 _ - : .) |
| [`IDEMPOTENCY_KEY_CONFLICT`](https://humanizethisai.com/errors/idempotency-key-conflict) | 409 | Idempotency-Key was already used under a different API key, user, or endpoint. Use a unique key per job. |
| [`RETRY_REQUIRED`](https://humanizethisai.com/errors/retry-required) | 409 | This Idempotency-Key was already consumed but no stored result is available (expired or original attempt refunded). Mint a new key and retry. |
| [`INVALID_FREEZE_WORDS`](https://humanizethisai.com/errors/invalid-freeze-words) | 400 | freeze_words is not an array, has more than 100 entries, or an entry exceeds 100 characters. |
| [`TONE_NAME_CONFLICT`](https://humanizethisai.com/errors/tone-name-conflict) | 409 | A custom tone with that name already exists for this account (case-insensitive). |
| [`TONE_NAME_RESERVED`](https://humanizethisai.com/errors/tone-name-reserved) | 400 | Custom tone name collides with a built-in tone (casual, formal, professional, academic, creative). |
| [`INSUFFICIENT_WORDS`](https://humanizethisai.com/errors/insufficient-words) | 402 | Not enough word balance — carries words_required and words_remaining extension fields |
| [`RESERVATION_FAILED`](https://humanizethisai.com/errors/reservation-failed) | 500 | Word reservation could not complete — retry. Not a payment or balance error. |
| [`TONE_NOT_FOUND`](https://humanizethisai.com/errors/tone-not-found) | 404 | No custom tone matches that name or UUID. List tones with GET /v1/tones or copy from the Custom Tones page. |
| [`TONE_LIMIT_REACHED`](https://humanizethisai.com/errors/tone-limit-reached) | 403 | Custom tone quota for the current plan has been reached. |
| [`NOT_FOUND`](https://humanizethisai.com/errors/not-found) | 404 | Unknown path |
| [`METHOD_NOT_ALLOWED`](https://humanizethisai.com/errors/method-not-allowed) | 405 | Path exists; HTTP method not supported (see Allow header) |
| [`INVALID_CONTENT_TYPE`](https://humanizethisai.com/errors/invalid-content-type) | 415 | Content-Type must be application/json on write requests |
| [`PAYLOAD_TOO_LARGE`](https://humanizethisai.com/errors/payload-too-large) | 413 | Request body exceeds the 128 KiB JSON body limit |
| [`VALIDATION_FAILED`](https://humanizethisai.com/errors/validation-failed) | 400 | Request failed validation (generic developer validation path) |
| [`CLIENT_CANCELLED`](https://humanizethisai.com/errors/client-cancelled) | 499 | Detect-only on developer API: client disconnected before /v1/detect completed — not charged / refunded. /v1/humanize cancels collapse to AI_GENERATION_FAILED. |
| [`AI_TIMEOUT`](https://humanizethisai.com/errors/ai-timeout) | 504 | Not emitted on developer /v1/humanize (collapsed to AI_GENERATION_FAILED 500). Detect timeouts collapse to DETECTION_FAILED. Present in internal classification / consumer paths. |
| [`AI_UNAVAILABLE`](https://humanizethisai.com/errors/ai-unavailable) | 503 | All AI providers unavailable — not charged / refunded |
| [`ABORTED`](https://humanizethisai.com/errors/aborted) | 504 | Request aborted mid-flight (AbortError) before a typed AI failure was classified |
| [`INTERNAL_ERROR`](https://humanizethisai.com/errors/internal-error) | 500 | Unexpected server error |
| [`AI_GENERATION_FAILED`](https://humanizethisai.com/errors/ai-generation-failed) | 500 | Humanize AI processing failed on /v1/humanize (timeouts, cancels, and generic AI failures collapse here) — not charged / refunded |
| [`DETECTION_FAILED`](https://humanizethisai.com/errors/detection-failed) | 500 | Detection processing failed on /v1/detect (non-cancel AI failures, including timeouts, collapse here) — not charged / refunded |

## Rate Limits

Primary endpoints (`/v1/humanize`, `/v1/detect`) use your plan RPM and concurrent caps. Secondary endpoints (`/v1/usage`, `/v1/tones`) share a separate **60 requests/minute per user** bucket so they do not consume primary budget. Tone writes add a further 10/min cap on POST/PUT/DELETE.

| Plan | Primary RPM | Concurrent | Keys | Secondary RPM | Tone writes / min |
|------|-------------|------------|------|---------------|-------------------|
| Growth | 60 | 10 | 5 | 60 (usage + tones shared) | 10 (POST/PUT/DELETE) |
| Scale | 200 | 30 | 15 | 60 (usage + tones shared) | 10 (POST/PUT/DELETE) |

Hitting the concurrent cap returns `429 CONCURRENT_LIMIT` (separate from RPM `RATE_LIMITED`). Empty word balance is `402 INSUFFICIENT_WORDS`, not 429.

### Response headers

`X-RateLimit-*` appear on `/v1/humanize` and `/v1/detect` (success and error). Secondary routes (`/v1/usage`, `/v1/tones`) include them on 429 only. They are not guaranteed on every endpoint or every status.

| Header | Description |
|--------|-------------|
| `X-RateLimit-Limit` | RPM cap for the bucket that applied (plan primary RPM, or 60 on secondary). |
| `X-RateLimit-Remaining` | Requests remaining in the current 60-second window for that bucket. |
| `X-RateLimit-Reset` | Seconds until the current window resets (a delta, not a Unix timestamp). 0 while you are within the limit. |
| `Retry-After` | Seconds to wait before retrying. Set when the RPM bucket is exhausted (RATE_LIMITED). Not guaranteed on CONCURRENT_LIMIT. |
| `X-Request-Id` | Canonical request id. Equals JSON success id and problem request_id. Equals your Idempotency-Key when you sent one. |
| `X-Idempotent-Replay` | true when this response is a stored replay (also body.replayed: true). |
| `Content-Type` | application/json \| application/problem+json \| text/event-stream |

```http
# Example rate-limited response
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 30
Retry-After: 30

{
  "type": "https://humanizethisai.com/errors/rate-limited",
  "title": "Rate limit exceeded",
  "status": 429,
  "detail": "Rate limit exceeded. Slow down.",
  "instance": "/v1/humanize",
  "code": "RATE_LIMITED",
  "request_id": "8f2a1c6e-3b4d-4f0a-9c12-7e5d8a1b2c3d"
}
```

## Code Examples

Runnable examples with authentication, idempotency keys, and error handling. Every sample uses at least 20 words so it will not fail validation.

### cURL

```bash
# Humanize (JSON)
curl -X POST https://api.humanizethisai.com/v1/humanize \
  -H "Authorization: Bearer htai_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "text": "Artificial intelligence has revolutionized the way we interact with technology. Machine learning models can now generate coherent text that closely resembles human writing across many domains.",
    "mode": "standard",
    "tone": "casual",
    "freeze_words": ["HumanizeThisAI", "GPTZero"],
    "best_of": 1,
    "language": "en",
    "stream": false
  }'

# Humanize (SSE stream)
curl -N -X POST https://api.humanizethisai.com/v1/humanize \
  -H "Authorization: Bearer htai_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "text": "Artificial intelligence has revolutionized the way we interact with technology. Machine learning models can now generate coherent text that closely resembles human writing across many domains.",
    "mode": "standard",
    "tone": "casual",
    "stream": true
  }'

# Detect
curl -X POST https://api.humanizethisai.com/v1/detect \
  -H "Authorization: Bearer htai_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "text": "Artificial intelligence has revolutionized the way we interact with technology. Machine learning models can now generate coherent text that closely resembles human writing across many domains." }'

# Usage
curl -X GET https://api.humanizethisai.com/v1/usage \
  -H "Authorization: Bearer htai_live_..."

# Create a custom tone
curl -X POST https://api.humanizethisai.com/v1/tones \
  -H "Authorization: Bearer htai_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Newsletter voice",
    "instructions": "Warm, concise, second-person. Short sentences."
  }'
```

### Python

```python
import uuid
import requests

API_KEY = "htai_live_..."
BASE = "https://api.humanizethisai.com"
HEADERS = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
}

def humanize(text: str) -> dict:
    response = requests.post(
        f"{BASE}/v1/humanize",
        headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
        json={
            "text": text,
            "mode": "standard",
            "tone": "casual",
            "freeze_words": ["HumanizeThisAI", "GPTZero"],
            "best_of": 1,
            "language": "en",
            "stream": False,
        },
        timeout=120,
    )
    if response.status_code == 200:
        # Flat response — fields sit at the top level (no "data" wrapper).
        result = response.json()
        print(result["humanized_text"])
        print(f"Charged {result['words_charged']} words, {result['words_remaining']} left")
        return result

    # Errors are RFC 7807 problem+json: { type, title, status, detail, code, ... }
    problem = response.json()
    print(f"Error [{problem.get('code')}]: {problem.get('detail')}")
    if response.status_code == 402:
        print(f"Need {problem.get('words_required')}, have {problem.get('words_remaining')}")
    elif response.status_code == 429:
        print(f"Retry after {response.headers.get('Retry-After')} seconds")
    response.raise_for_status()
    return {}

def detect(text: str) -> dict:
    response = requests.post(
        f"{BASE}/v1/detect",
        headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
        json={"text": text},
        timeout=120,
    )
    response.raise_for_status()
    return response.json()

if __name__ == "__main__":
    sample = "Artificial intelligence has revolutionized the way we interact with technology. Machine learning models can now generate coherent text that closely resembles human writing across many domains."
    humanize(sample)
    print(detect(sample)["verdict"])
```

### Node.js

```javascript
import { randomUUID } from "node:crypto";

const API_KEY = "htai_live_...";
const BASE = "https://api.humanizethisai.com";

async function humanize(text) {
  const response = await fetch(`${BASE}/v1/humanize`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": randomUUID(),
    },
    body: JSON.stringify({
      text,
      mode: "standard",
      tone: "casual",
      freeze_words: ["HumanizeThisAI", "GPTZero"],
      best_of: 1,
      language: "en",
      stream: false,
    }),
  });

  if (!response.ok) {
    const problem = await response.json();
    console.error(`[${problem.code}] ${problem.detail}`);
    if (response.status === 402) {
      console.log(`Need ${problem.words_required}, have ${problem.words_remaining}`);
    } else if (response.status === 429) {
      console.log(`Retry after ${response.headers.get("Retry-After")}s`);
    }
    throw new Error(`API error ${response.status}`);
  }

  const result = await response.json();
  console.log(result.humanized_text);
  console.log(`${result.words_charged} charged, ${result.words_remaining} left`);
  return result;
}

async function humanizeStream(text) {
  const response = await fetch(`${BASE}/v1/humanize`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": randomUUID(),
    },
    body: JSON.stringify({ text, mode: "standard", tone: "casual", stream: true }),
  });

  if (!response.ok) {
    const problem = await response.json();
    throw new Error(`[${problem.code}] ${problem.detail}`);
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  let output = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    const parts = buffer.split("\n\n");
    buffer = parts.pop() ?? "";
    for (const part of parts) {
      const lines = part.split("\n");
      const event = lines.find((l) => l.startsWith("event:"))?.slice(6).trim();
      const dataLine = lines.find((l) => l.startsWith("data:"))?.slice(5).trim();
      if (!dataLine) continue;
      const data = JSON.parse(dataLine);
      if (event === "chunk") output += data.text ?? "";
      if (event === "error") throw new Error(data.message ?? data.code);
      if (event === "done") return { output, done: data };
    }
  }
  return { output };
}

const sample = "Artificial intelligence has revolutionized the way we interact with technology. Machine learning models can now generate coherent text that closely resembles human writing across many domains.";
await humanize(sample);
// const streamed = await humanizeStream(sample);
```

### Go

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"

	"github.com/google/uuid"
)

func main() {
	payload, _ := json.Marshal(map[string]interface{}{
		"text":         `Artificial intelligence has revolutionized the way we interact with technology. Machine learning models can now generate coherent text that closely resembles human writing across many domains.`,
		"mode":         "standard",
		"tone":         "casual",
		"freeze_words": []string{"HumanizeThisAI", "GPTZero"},
		"best_of":      1,
		"language":     "en",
		"stream":       false,
	})

	req, _ := http.NewRequest(
		"POST",
		"https://api.humanizethisai.com/v1/humanize",
		bytes.NewBuffer(payload),
	)
	req.Header.Set("Authorization", "Bearer htai_live_...")
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", uuid.NewString())

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != 200 {
		var problem map[string]interface{}
		_ = json.Unmarshal(body, &problem)
		fmt.Printf("Error [%v]: %v\n", problem["code"], problem["detail"])
		if resp.StatusCode == 402 {
			fmt.Printf("Need %v, have %v\n", problem["words_required"], problem["words_remaining"])
		} else if resp.StatusCode == 429 {
			fmt.Printf("Retry-After: %s\n", resp.Header.Get("Retry-After"))
		}
		return
	}

	var result map[string]interface{}
	_ = json.Unmarshal(body, &result)
	fmt.Println(result["humanized_text"])
	fmt.Printf("charged=%v remaining=%v\n", result["words_charged"], result["words_remaining"])
}
```

---

Website: https://humanizethisai.com/docs/api
Markdown: https://humanizethisai.com/docs/api.md
