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

## 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/stealthapi).
</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. This endpoint does not generate a new essay from a prompt.
</ParamField>

<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. `"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.
* 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.
