> ## 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.

# Writer & Humanizer Async Runs

> Create and poll asynchronous humanization runs with optional webhook callbacks

The async Writer & Humanizer API lets you humanize existing text without keeping a long HTTP connection open. Use it for Zapier, n8n, agent platforms, workflow engines, or any integration where the synchronous `/api/stealthify` request may exceed the caller's timeout.

<Info>
  The existing [`POST /api/stealthify`](/api-reference/endpoints/stealthify) endpoint is unchanged and remains supported for current API clients. This async API is an alternative for long-running humanization, not a replacement.
</Info>

<Info>
  Humanization automatically strips invisible AI text watermarks. Claude and Gemini embed a statistical pattern in their word choices, invisible to a reader, but it travels with copy and paste and can survive light editing. The humanizer rewrites at the sentence and structure level rather than swapping synonyms, which is the category of change those marks are not built to survive. Meaning stays intact; Claude, Gemini SynthID, and other invisible text watermarks do not.
</Info>

## Flow

<Steps>
  <Step title="Create a run">
    Send `POST /api/stealthify/runs` with text to humanize, or upload a PDF/DOCX file as `multipart/form-data`. Optionally include `webhookUrl`, `webhookSecret`, and `idempotency-key`.
  </Step>

  <Step title="Store the run id">
    The API returns `202 Accepted` with `runId`, `status: "queued"`, and `statusUrl`.
  </Step>

  <Step title="Poll or wait for webhook">
    Call `GET /api/stealthify/runs/{runId}` until `status` is `completed`, `failed`, or `cancelled`. If `webhookUrl` was provided, StealthGPT also sends the terminal payload to that URL.
  </Step>
</Steps>

## Authentication

<ParamField header="api-token" type="string" required>
  Your Stealth API token from the [Stealth API dashboard](https://www.stealthgpt.ai/api-dashboard).
</ParamField>

## Create a run

`POST https://stealthgpt.ai/api/stealthify/runs`

### Headers

<ParamField header="api-token" type="string" required>
  Your Stealth API token.
</ParamField>

<ParamField header="Content-Type" type="string" required>
  `application/json` to send text, or `multipart/form-data` to upload a file.
</ParamField>

<ParamField header="idempotency-key" type="string">
  Optional key, 1-255 characters. Reuse the same key when retrying the same create request to avoid enqueueing duplicate runs.
</ParamField>

### Request body

<ParamField body="text" type="string" required>
  Existing text to humanize — send only that source text, with no instruction wrappers. This endpoint does not generate a new essay from a prompt.
</ParamField>

<Warning>
  Put **only the source text** in `text`. Do not wrap it with instructions such as "Humanize the following text", "Rewrite this to sound more human", or "Make this undetectable". Those prefixes confuse the model and hurt output quality. Use `qualityMode`, `model`, and `outputFormat` for control instead.
</Warning>

<ParamField body="qualityMode" type="string" default="quality">
  `"quality"` runs quality checks and repairs. `"fast"` performs a faster single-pass rewrite.
</ParamField>

<ParamField body="model" type="string" default="heavy">
  `"heavy"` uses the full StealthGPT humanizer pipeline and strips invisible text watermarks such as Claude's mark and Gemini's SynthID. `"lite"` is faster and intended for light AI-smell cleanup.
</ParamField>

<ParamField body="outputFormat" type="string" default="text">
  `"text"` returns plain text. `"markdown"` preserves/restores markdown formatting where supported.
</ParamField>

<ParamField body="webhookUrl" type="string">
  Optional URL that receives a `POST` callback when the run reaches a terminal status.
</ParamField>

<ParamField body="webhookSecret" type="string">
  Optional signing secret, 16-255 characters. When present, StealthGPT signs webhook callbacks with HMAC-SHA256.
</ParamField>

### Request body (file upload)

Instead of JSON, you can send `multipart/form-data` to humanize the contents of a document. The text is extracted server-side and the run behaves exactly like a text run: the humanized result is returned as plain text in the `result` field.

<ParamField body="file" type="file" required>
  The document to humanize. Supported types: `.pdf`, `.docx`, `.txt`, `.md`. Maximum size: 4MB. Scanned or image-only documents are not supported.
</ParamField>

All optional JSON fields (`qualityMode`, `model`, `outputFormat`, `webhookUrl`, `webhookSecret`) can be sent as additional form fields.

```bash curl theme={null}
curl --request POST \
  --url 'https://stealthgpt.ai/api/stealthify/runs' \
  --header 'api-token: YOUR_API_TOKEN' \
  --form 'file=@essay.pdf' \
  --form 'qualityMode=quality' \
  --form 'model=heavy'
```

Billing is identical to text runs: input words extracted from the file plus output words, at 1 credit per word.

### Response: 202 Accepted

<ResponseField name="runId" type="string" required>
  Identifier of the queued humanization run.
</ResponseField>

<ResponseField name="status" type="string" required>
  Always `"queued"` for a successful create response.
</ResponseField>

<ResponseField name="statusUrl" type="string" required>
  Relative polling URL: `/api/stealthify/runs/{runId}`.
</ResponseField>

```json theme={null}
{
  "runId": "stealthify-api-run:abc123",
  "status": "queued",
  "statusUrl": "/api/stealthify/runs/stealthify-api-run:abc123"
}
```

## Poll run status

`GET https://stealthgpt.ai/api/stealthify/runs/{runId}`

### Headers

<ParamField header="api-token" type="string" required>
  Your Stealth API token. The run must belong to this token's account.
</ParamField>

### Path parameters

<ParamField path="runId" type="string" required>
  The `runId` returned from `POST /api/stealthify/runs`.
</ParamField>

### Queued response

```json theme={null}
{
  "runId": "stealthify-api-run:abc123",
  "status": "queued"
}
```

### Running response

```json theme={null}
{
  "runId": "stealthify-api-run:abc123",
  "createdAt": "2026-05-28T14:55:00.000Z",
  "updatedAt": "2026-05-28T14:55:03.000Z",
  "currentOperation": "humanizing",
  "status": "running"
}
```

### Completed response

The completed polling response is also the successful webhook callback body.

<ResponseField name="result" type="string" required>
  Humanized output text.
</ResponseField>

<ResponseField name="howLikelyToBeDetected" type="number" required>
  Human-likeness score from 0 to 100. **Higher is better**: a higher score means the result reads as more human and is less likely to be flagged as AI; a lower score means it is more likely to be detected as AI-generated. (The field name is historical — treat a higher value as the better result.)
</ResponseField>

<ResponseField name="wordsSpent" type="number" required>
  Total billed words for this run: input words plus output words.
</ResponseField>

<ResponseField name="creditsSpent" type="number" required>
  Credits charged for this run. Humanization bills 1 credit per word, so this
  equals `wordsSpent`.
</ResponseField>

<ResponseField name="remainingCredits" type="number" required>
  Prepaid word balance after this charge.
</ResponseField>

<ResponseField name="billingMode" type="string" required>
  `"prepaid"` when covered from balance, or `"payg"` when metered usage was applied.
</ResponseField>

<ResponseField name="meteredChargedCredits" type="number" required>
  Words billed to metered usage for this request (0 when fully prepaid).
</ResponseField>

```json theme={null}
{
  "runId": "stealthify-api-run:abc123",
  "createdAt": "2026-05-28T14:55:00.000Z",
  "updatedAt": "2026-05-28T14:56:12.000Z",
  "currentOperation": null,
  "status": "completed",
  "result": "Renewable energy has clear benefits for communities and the climate...",
  "howLikelyToBeDetected": 92,
  "wordsSpent": 260,
  "creditsSpent": 260,
  "remainingCredits": 985000,
  "billingMode": "prepaid",
  "meteredChargedCredits": 0
}
```

### Failed response

```json theme={null}
{
  "runId": "stealthify-api-run:abc123",
  "createdAt": "2026-05-28T14:55:00.000Z",
  "updatedAt": "2026-05-28T14:56:12.000Z",
  "currentOperation": null,
  "status": "failed",
  "error": {
    "code": "humanize_failed",
    "message": "Humanization failed"
  }
}
```

Possible `error.code` values:

| Code                  | Meaning                                                                        |
| --------------------- | ------------------------------------------------------------------------------ |
| `humanize_failed`     | The humanization pipeline failed before output was available.                  |
| `payg_terms_required` | The user ran out of prepaid credits and has not accepted pay-as-you-go terms.  |
| `payment_required`    | Credits or pay-as-you-go access were not available when billing was finalized. |
| `billing_delayed`     | Billing was temporarily delayed; create a new run later if needed.             |
| `result_unavailable`  | The run completed, but its linked humanized result is no longer available.     |
| `cancelled`           | The run was cancelled before completion.                                       |

## Webhook callbacks

If `webhookUrl` is present in the create request, StealthGPT sends a best-effort `POST` when the run reaches a terminal status: `completed`, `failed`, or `cancelled`.

### Headers

<ResponseField name="content-type" type="string">
  `application/json`
</ResponseField>

<ResponseField name="user-agent" type="string">
  `StealthGPT-API-Webhook/1.0`
</ResponseField>

<ResponseField name="x-stealthgpt-event" type="string">
  `stealthify.run.completed`, `stealthify.run.failed`, or `stealthify.run.cancelled`.
</ResponseField>

<ResponseField name="x-stealthgpt-run-id" type="string">
  The run id.
</ResponseField>

<ResponseField name="x-stealthgpt-timestamp" type="string">
  Unix timestamp in seconds. Present on every webhook callback.
</ResponseField>

<ResponseField name="x-stealthgpt-signature" type="string">
  Present when `webhookSecret` was provided. Format: `v1={hex_hmac_sha256}`.
</ResponseField>

### Delivery behavior

* Webhook delivery uses a 10-second timeout.
* Network errors, timeouts, `429`, and `5xx` responses are retried up to 3 attempts.
* `4xx` responses other than `429` are treated as final failures.
* Webhook delivery is best-effort: a failed callback does not change the run status.
* The callback body is the same terminal payload returned by `GET /api/stealthify/runs/{runId}`.
* When `webhookSecret` is provided, the signature is computed over `${timestamp}.${rawBody}` using HMAC-SHA256.
* Reject signed callbacks with old timestamps. A 5-minute tolerance is recommended.

### Verify a signature

Use the raw request body exactly as received. Re-serializing parsed JSON can change whitespace and produce a different signature.

```javascript nodejs theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto'

const WEBHOOK_TOLERANCE_SECONDS = 5 * 60

function verifyStealthGptWebhook({ rawBody, timestamp, signature, secret }) {
  if (!signature) return false

  const timestampSeconds = Number(timestamp)
  const nowSeconds = Math.floor(Date.now() / 1000)

  if (
    !Number.isFinite(timestampSeconds) ||
    Math.abs(nowSeconds - timestampSeconds) > WEBHOOK_TOLERANCE_SECONDS
  ) {
    return false
  }

  const [version, receivedDigest] = signature.split('=')

  if (version !== 'v1' || !receivedDigest) return false

  const expectedDigest = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')

  const expected = Buffer.from(expectedDigest, 'hex')
  const received = Buffer.from(receivedDigest, 'hex')

  return (
    expected.length === received.length &&
    timingSafeEqual(expected, received)
  )
}
```

## Examples

<CodeGroup>
  ```bash curl theme={null}
  curl --request POST \
    --url 'https://stealthgpt.ai/api/stealthify/runs' \
    --header 'Content-Type: application/json' \
    --header 'api-token: YOUR_API_TOKEN' \
    --header 'idempotency-key: humanize-job-123' \
    --data '{
      "text": "Renewable energy offers several significant advantages in our modern world...",
      "qualityMode": "quality",
      "model": "heavy",
      "outputFormat": "text",
      "webhookUrl": "https://example.com/webhooks/stealthify",
      "webhookSecret": "whsec_your_random_signing_secret"
    }'
  ```

  ```python python theme={null}
  import time
  import requests

  headers = {
      "api-token": "YOUR_API_TOKEN",
      "Content-Type": "application/json",
      "idempotency-key": "humanize-job-123",
  }

  create_response = requests.post(
      "https://stealthgpt.ai/api/stealthify/runs",
      headers=headers,
      json={
          "text": "Renewable energy offers several significant advantages in our modern world...",
          "qualityMode": "quality",
          "model": "heavy",
          "outputFormat": "text",
      },
      timeout=30,
  )
  create_response.raise_for_status()

  run = create_response.json()
  status_url = f"https://stealthgpt.ai{run['statusUrl']}"

  while True:
      status_response = requests.get(
          status_url,
          headers={"api-token": "YOUR_API_TOKEN"},
          timeout=30,
      )
      status_response.raise_for_status()
      status = status_response.json()

      if status["status"] in ("completed", "failed", "cancelled"):
          print(status)
          break

      time.sleep(10)
  ```

  ```javascript nodejs theme={null}
  const createResponse = await fetch('https://stealthgpt.ai/api/stealthify/runs', {
    method: 'POST',
    headers: {
      'api-token': process.env.STEALTHGPT_API_TOKEN,
      'Content-Type': 'application/json',
      'idempotency-key': 'humanize-job-123',
    },
    body: JSON.stringify({
      text: 'Renewable energy offers several significant advantages in our modern world...',
      qualityMode: 'quality',
      model: 'heavy',
      outputFormat: 'text',
    }),
    signal: AbortSignal.timeout(30_000),
  })

  if (!createResponse.ok) {
    throw new Error(`create failed: ${createResponse.status}`)
  }

  const run = await createResponse.json()

  for (;;) {
    const statusResponse = await fetch(`https://stealthgpt.ai${run.statusUrl}`, {
      headers: { 'api-token': process.env.STEALTHGPT_API_TOKEN },
      signal: AbortSignal.timeout(30_000),
    })

    if (!statusResponse.ok) {
      throw new Error(`poll failed: ${statusResponse.status}`)
    }

    const status = await statusResponse.json()

    if (['completed', 'failed', 'cancelled'].includes(status.status)) {
      console.log(status)
      break
    }

    await new Promise((resolve) => setTimeout(resolve, 10_000))
  }
  ```
</CodeGroup>

## Errors

| Endpoint                           | Status  | Meaning                                                                                                             |
| ---------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------- |
| `POST /api/stealthify/runs`        | **400** | Invalid request body, `idempotency-key`, or uploaded file (unsupported type, over 4MB, empty, or no readable text). |
| `POST /api/stealthify/runs`        | **401** | Missing `api-token` header or token not found.                                                                      |
| `POST /api/stealthify/runs`        | **402** | Insufficient credits, pay-as-you-go not available, or payment method missing.                                       |
| `POST /api/stealthify/runs`        | **500** | Server error while creating the run.                                                                                |
| `GET /api/stealthify/runs/{runId}` | **400** | Invalid `runId`.                                                                                                    |
| `GET /api/stealthify/runs/{runId}` | **401** | Missing `api-token` header or token not found.                                                                      |
| `GET /api/stealthify/runs/{runId}` | **404** | Run not found or belongs to another account.                                                                        |
| `GET /api/stealthify/runs/{runId}` | **500** | Server error while reading status.                                                                                  |

## Usage notes

* Use `idempotency-key` whenever your create request can be retried by a queue, workflow engine, or HTTP client.
* Poll every 5-15 seconds; there is no benefit to polling every second.
* Store `runId`, `statusUrl`, and your own job id together so webhook handlers can reconcile callbacks with queued work.
* Send only the source text in `text` — no "humanize / rewrite / make undetectable" instruction wrappers.
* This endpoint only humanizes existing text. To generate a new essay or article from a prompt, keep using [`POST /api/stealthify`](/api-reference/endpoints/stealthify) or [`POST /api/stealthify/agent`](/api-reference/endpoints/stealthify-agent), depending on the workflow.
