# Text detections

> Classify plain text as AI-written, mixed or human-written with POST /text/detections. Request and response fields, segment offsets, limits and billing.

Source: https://fileverity.com/en/docs/text-detections

`POST /text/detections` runs one text check. It classifies plain text with `fvdoc-3-text` and returns the AI-written passages. It is synchronous: the result is in the response.

The examples read the text from a file, so save any passage of 100 words or more as `essay.txt` first. The [Quickstart](https://fileverity.com/en/docs/quickstart.md#text) has a command that writes a sample.

**Request: cURL**

```bash
curl https://api.fileverity.com/v1/text/detections \
  -H "Authorization: Bearer $FILEVERITY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6b9d1c1e-3b0f-4c0a-9a53-2d1c9d6b7e11" \
  -d "$(jq -Rs '{text: ., language: "en"}' essay.txt)"
```

**Request: JavaScript**

```javascript
import { readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";

const response = await fetch("https://api.fileverity.com/v1/text/detections", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + process.env.FILEVERITY_API_KEY,
    "Content-Type": "application/json",
    "Idempotency-Key": randomUUID(),
  },
  body: JSON.stringify({ text: await readFile("essay.txt", "utf8"), language: "en" }),
});
const detection = await response.json();
```

**Request: Python**

```python
import os
import uuid
import requests

response = requests.post(
    "https://api.fileverity.com/v1/text/detections",
    headers={
        "Authorization": "Bearer " + os.environ["FILEVERITY_API_KEY"],
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={"text": open("essay.txt", encoding="utf-8").read(), "language": "en"},
)
detection = response.json()
```

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `text` | string | Yes | Plain text of at least 100 words and at most 100,000 characters. |
| `model` | string | No | Default `fvdoc-3-text`. Accepts the model ID or its snapshot ID `fvdoc-3-text-2026-11-03`. |
| `language` | string or null | No | ISO 639-1 code: `en`, `fr`, `de`, `es`, `zh`. Detected automatically when omitted. |

Unknown fields are rejected with `invalid_parameter`. When the 100-word minimum is checked, Chinese characters count about two to a word.

## Response

**200 OK**

```json
{
  "id": "tdet_8Hk2pQ4vN9sL1xR7cW3mY6zB",
  "object": "text.detection",
  "created": 1793664000,
  "model": "fvdoc-3-text-2026-11-03",
  "livemode": true,
  "language": "en",
  "verdict": "mixed",
  "score": 0.97,
  "ai_fraction": 0.41,
  "segments": [
    {
      "start": 212,
      "end": 433,
      "score": 0.97
    },
    {
      "start": 579,
      "end": 790,
      "score": 0.93
    }
  ],
  "usage": {
    "characters": 1412,
    "free_characters": 0,
    "billed_units": 2
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Detection ID, prefixed `tdet_`. |
| `object` | string | Always `text.detection`. |
| `created` | integer | Unix timestamp. |
| `model` | string | Snapshot that produced the result. |
| `livemode` | boolean | `false` for test keys. |
| `language` | string | The language used: the one you sent, or the detected one. |
| `verdict` | string | `ai`: the text reads as AI-written throughout. `mixed`: AI-written passages within human text. `human`: no AI-written passage found. |
| `score` | number | From 0 to 1. Model score for the most AI-like passage. |
| `ai_fraction` | number | From 0 to 1. Share of the evaluated text inside AI-written segments. |
| `segments` | array | The AI-written passages. Each has `start`, `end` (exclusive) and `score`. |
| `usage` | object | `characters` evaluated, `free_characters` covered by your free start and `billed_units` actually charged after the free characters. |

> **Note.** Text detection returns a statistical estimate, not proof of authorship. Segment boundaries are approximate. Use it as one input to a human review, and do not use it alone to make decisions that affect a person.

## Segment offsets

`start` and `end` are offsets into the text you submitted, counted in Unicode code points, not bytes and not UTF-16 units. Segment boundaries are approximate and can include adjacent sentences. JavaScript strings index by UTF-16, so split into code points first:

**Slice a segment: JavaScript**

```javascript
const points = Array.from(text);
for (const { start, end } of detection.segments) {
  console.log(points.slice(start, end).join(""));
}
```

**Slice a segment: Python**

```python
for segment in detection["segments"]:
    print(text[segment["start"]:segment["end"]])
```

Python strings already index by code point.

## Billing

- Text is billed per 1,000 characters at US$0.14, counted in Unicode code points. Each request is rounded up to whole units, with a minimum of one unit.
- Your free-start text characters are used first and reported in `usage.free_characters`. `usage.billed_units` is the number of units charged after them, so the cost is `billed_units` times US$0.14. Example: 1,412 characters with no free characters left bill 2 units, US$0.28.
- Requests rejected before evaluation are not charged: invalid parameters, text that is too short or too long, unsupported languages and similar. A check that fails inside the service (`service_unavailable`) is released and not charged.
- Insufficient funds return `insufficient_balance` (402).

## What is stored

The text is evaluated and discarded. It is never stored, so it cannot be retrieved later.

Fileverity keeps the result without the text (verdict, scores, passage offsets and usage) for 7 days, so the Console can show it and usage can be billed and audited. A retried request with the same `Idempotency-Key` gets the saved result for 24 hours. Text detections are synchronous: they do not appear in `GET /analyses` and do not send webhook events. Test keys record nothing. See [Data retention and security](https://fileverity.com/en/docs/security.md).

## Errors

| Code | Status | Cause |
| --- | --- | --- |
| `text_too_short` | 400 | Fewer than 100 words. |
| `text_too_long` | 413 | More than 100,000 characters. Split the text into several requests. |
| `unsupported_language` | 400 | The language is not supported, or could not be detected. |
| `unsupported_media_type` | 415 | The body is not sent as `application/json`. |
| `invalid_json` | 400 | The body is not a JSON object. |
| `missing_parameter` | 400 | The `text` field is missing. |
| `invalid_parameter` | 400 | A field has the wrong type, an unknown field was sent, or the `Idempotency-Key` is invalid. |
| `model_not_found` | 404 | The `model` is unknown. |
| `insufficient_balance` | 402 | The balance cannot cover the request. |
| `spend_limit_reached` | 402 | The key reached its monthly spend limit. |
| `idempotency_mismatch`, `idempotency_in_progress` | 409 | The `Idempotency-Key` was used with a different body, or its first request is still running. |
| `service_unavailable`, `analysis_failed` | 503, 502 | The detection service is temporarily unavailable or busy. Not charged; retry. `service_unavailable` carries a `retry-after` header. |

> **Note.** Send an `Idempotency-Key` so that retrying after a timeout does not bill twice. See [Idempotency](https://fileverity.com/en/docs/idempotency.md).

