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

# Stealth Agent Async Runs

> Create and poll asynchronous Stealth Agent runs with optional webhook callbacks

The async Stealth Agent API lets you start a long-running generation job, return immediately, and receive the final result later by polling or webhook callback. Use it for integrations, automations, UI backends, and agent platforms where a multi-minute HTTP request is unreliable.

<Info>
  If you need a simple one-off script and can keep the connection open for up to 10 minutes, use the synchronous [`POST /api/stealthify/agent`](/api-reference/endpoints/stealthify-agent) endpoint. For production integrations, prefer async runs.
</Info>

## Flow

<Steps>
  <Step title="Create a run">
    Send `POST /api/stealthify/agent/runs` with the same preset body as the synchronous endpoint. Optionally include `webhookUrl` 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/agent/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/agent/runs`

### Headers

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

<ParamField header="Content-Type" type="string" required>
  Must be `application/json`.
</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

The async create body uses the same strict `preset` discriminated union as [`POST /api/stealthify/agent`](/api-reference/endpoints/stealthify-agent), plus optional `webhookUrl` and `webhookSecret`.

<ParamField body="preset" type="string" required>
  One of `"academic"`, `"seo"`, or `"social"`.
</ParamField>

<ParamField body="prompt" type="string" required>
  Topic or instructions for the document (non-empty).
</ParamField>

<ParamField body="platform" type="string">
  Required only when `preset` is `"social"`. Must be `"linkedin"` or `"medium"`.
</ParamField>

<ParamField body="enableFactCheck" type="boolean" default="false">
  When `true`, runs a fact-check pass on the draft and applies fixes before humanization.
</ParamField>

<ParamField body="enableImageGeneration" type="boolean" default="false">
  When `true`, may embed generated images in the final markdown 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>

### Response: 202 Accepted

<ResponseField name="runId" type="string" required>
  Identifier of the queued Stealth Agent 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/agent/runs/{runId}`.
</ResponseField>

```json theme={null}
{
  "runId": "run_abc123",
  "status": "queued",
  "statusUrl": "/api/stealthify/agent/runs/run_abc123"
}
```

## Poll run status

`GET https://stealthgpt.ai/api/stealthify/agent/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/agent/runs`.
</ParamField>

### Queued response

Returned before the generation row has started.

```json theme={null}
{
  "runId": "run_abc123",
  "status": "queued"
}
```

### Running response

<ResponseField name="runId" type="string" required>
  Run identifier.
</ResponseField>

<ResponseField name="preset" type="string" required>
  `academic`, `seo`, or `social`.
</ResponseField>

<ResponseField name="status" type="string" required>
  `"running"`.
</ResponseField>

<ResponseField name="createdAt" type="string" required>
  ISO timestamp for run creation.
</ResponseField>

<ResponseField name="updatedAt" type="string" required>
  ISO timestamp for the latest run update.
</ResponseField>

<ResponseField name="currentStepId" type="string | null" required>
  Current pipeline step, or `null` while the run is starting. Possible step ids include `web_research`, `keywords_research`, `competitor_analysis`, `social_research`, `generate_text`, `meta_generation`, `fact_check`, `humanize`, `image_generation`, and `citations`.
</ResponseField>

<ResponseField name="currentOperation" type="string | null" required>
  Human-readable current operation, or `null` while the run is starting.
</ResponseField>

```json theme={null}
{
  "runId": "run_abc123",
  "preset": "academic",
  "createdAt": "2026-05-27T14:55:00.000Z",
  "updatedAt": "2026-05-27T14:56:22.000Z",
  "currentStepId": "humanize",
  "currentOperation": "Humanizing draft",
  "status": "running"
}
```

### Completed response

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

<ResponseField name="documentId" type="string" required>
  Identifier of the saved document record for this run.
</ResponseField>

<ResponseField name="result" type="string" required>
  Final content as markdown.
</ResponseField>

<ResponseField name="outputWords" type="number" required>
  Word count of `result` used for billing.
</ResponseField>

<ResponseField name="creditsSpent" type="number" required>
  Stealth API words charged for this request.
</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": "run_abc123",
  "preset": "academic",
  "createdAt": "2026-05-27T14:55:00.000Z",
  "updatedAt": "2026-05-27T14:59:12.000Z",
  "currentStepId": "citations",
  "currentOperation": "Inserting citations",
  "status": "completed",
  "documentId": "doc_123",
  "result": "# Creatine and resistance training in older adults\n\n...",
  "outputWords": 1420,
  "creditsSpent": 14200,
  "remainingCredits": 485800,
  "billingMode": "prepaid",
  "meteredChargedCredits": 0
}
```

### Failed response

`failed` can mean generation failed or billing could not be completed after generation.

```json theme={null}
{
  "runId": "run_abc123",
  "preset": "academic",
  "createdAt": "2026-05-27T14:55:00.000Z",
  "updatedAt": "2026-05-27T14:58:30.000Z",
  "currentStepId": "generate_text",
  "currentOperation": "Generating draft",
  "status": "failed",
  "error": {
    "code": "generation_failed",
    "message": "Stealth Agent generation failed"
  }
}
```

Possible `error.code` values:

| Code                  | Meaning                                                                        |
| --------------------- | ------------------------------------------------------------------------------ |
| `generation_failed`   | The generation pipeline failed before a completed document was available.      |
| `payg_terms_required` | Credits ran out and pay-as-you-go terms have not been accepted.                |
| `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.             |
| `cancelled`           | The run was cancelled before completion.                                       |

### Cancelled response

```json theme={null}
{
  "runId": "run_abc123",
  "preset": "academic",
  "createdAt": "2026-05-27T14:55:00.000Z",
  "updatedAt": "2026-05-27T14:56:10.000Z",
  "currentStepId": "web_research",
  "currentOperation": "Researching sources",
  "status": "cancelled",
  "error": {
    "code": "cancelled",
    "message": "Stealth Agent generation was cancelled"
  }
}
```

## 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">
  `stealth_agent.run.completed`, `stealth_agent.run.failed`, or `stealth_agent.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/agent/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/agent/runs' \
    --header 'Content-Type: application/json' \
    --header 'api-token: YOUR_API_TOKEN' \
    --header 'idempotency-key: article-123' \
    --data '{
      "preset": "academic",
      "prompt": "Outline creatine benefits for older adults in resistance training",
      "enableFactCheck": true,
      "enableImageGeneration": false,
      "webhookUrl": "https://example.com/webhooks/stealth-agent",
      "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": "article-123",
  }

  create_response = requests.post(
      "https://stealthgpt.ai/api/stealthify/agent/runs",
      headers=headers,
      json={
          "preset": "academic",
          "prompt": "Outline creatine benefits for older adults in resistance training",
          "enableFactCheck": True,
          "enableImageGeneration": False,
          "webhookUrl": "https://example.com/webhooks/stealth-agent",
          "webhookSecret": "whsec_your_random_signing_secret",
      },
      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/agent/runs',
    {
      method: 'POST',
      headers: {
        'api-token': process.env.STEALTHGPT_API_TOKEN,
        'Content-Type': 'application/json',
        'idempotency-key': 'article-123',
      },
      body: JSON.stringify({
        preset: 'academic',
        prompt: 'Outline creatine benefits for older adults in resistance training',
        enableFactCheck: true,
        enableImageGeneration: false,
        webhookUrl: 'https://example.com/webhooks/stealth-agent',
        webhookSecret: 'whsec_your_random_signing_secret',
      }),
      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 /runs`        | **400** | Invalid request body or `idempotency-key`.                                                       |
| `POST /runs`        | **401** | Missing `api-token` header or token not found.                                                   |
| `POST /runs`        | **402** | Insufficient credits, pay-as-you-go not available, payment method missing, or billing cap delay. |
| `POST /runs`        | **500** | Server error while creating the run.                                                             |
| `GET /runs/{runId}` | **400** | Invalid `runId`.                                                                                 |
| `GET /runs/{runId}` | **401** | Missing `api-token` header or token not found.                                                   |
| `GET /runs/{runId}` | **404** | Run not found or belongs to another account.                                                     |
| `GET /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.
* Billing uses **output words** only: charged words = `ceil(outputWords × 10)` toward your Stealth API balance.
