> ## Documentation Index
> Fetch the complete documentation index at: https://docs.wibbleai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors and handling

> The Wibble error envelope, every error code, and how to handle and retry each one.

When a request fails, the Wibble API returns a non-2xx HTTP status and a JSON body describing what went wrong. The HTTP status tells you the broad category; the `error.code` field gives you a stable identifier to branch on in your code.

## The error envelope

Every error response shares the same shape: an `error` object with a machine-readable `code` and a human-readable `message`.

```json theme={null}
{
  "error": {
    "code": "not_found",
    "message": "Job not found."
  }
}
```

Branch on `error.code`, not on `message`. Messages are written for humans and may change; codes are part of the contract.

Some errors add fields alongside `error` at the top level of the body. For example, a `402 insufficient_words` response includes your current `balance` and the `words_required` for the request:

```json theme={null}
{
  "error": {
    "code": "insufficient_words",
    "message": "This request needs 240 API words, but the account only has 120 remaining."
  },
  "balance": 120,
  "words_required": 240
}
```

The table below lists which codes carry extra fields, and the examples further down show their full bodies.

## Error reference

| Status | Code                       | When it happens                                                                           | Extra fields                      |
| ------ | -------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------- |
| `400`  | `bad_request`              | The request body was not valid JSON.                                                      | —                                 |
| `400`  | `validation_error`         | A field failed schema validation, including a rejected `webhook_url`.                     | —                                 |
| `400`  | `word_limit_exceeded`      | `text` exceeds the 2,000-word limit.                                                      | —                                 |
| `401`  | `unauthorized`             | The API key is missing, invalid, expired, or revoked, or lacks the `humanize` scope.      | —                                 |
| `402`  | `insufficient_words`       | Your word balance cannot cover the request.                                               | `balance`, `words_required`       |
| `404`  | `not_found`                | No job with that id exists on your account.                                               | —                                 |
| `409`  | `idempotency_conflict`     | An `Idempotency-Key` was reused with a different request body within 24 hours.            | `existing_job_id`, `expires_at`   |
| `413`  | `payload_too_large`        | The request body is over 128,000 bytes.                                                   | —                                 |
| `429`  | `rate_limited`             | You exceeded the per-key rate limit (20 POST/min, 120 GET/min).                           | `retry_after_seconds`, `reset_at` |
| `429`  | `too_many_concurrent_jobs` | You have more than 5 active jobs at once.                                                 | `active_jobs`, `limit`            |
| `500`  | `enqueue_failed`           | The job was created but could not be enqueued. Reserved words are refunded automatically. | —                                 |

Both `429` codes also send a `Retry-After` header. The `401 unauthorized` response does not include rate-limit headers.

## Per-code detail

<AccordionGroup>
  <Accordion title="400 bad_request">
    The request body could not be parsed as JSON. Check for trailing commas, unquoted keys, or a missing `Content-Type: application/json` header. Fix the body before resending; retrying the same payload returns the same error.
  </Accordion>

  <Accordion title="400 validation_error">
    A field is missing or has the wrong type or value. This also covers a `webhook_url` that is rejected for not being HTTPS, not being publicly reachable, containing embedded credentials, or resolving to a private or loopback address. Correct the field and resend.
  </Accordion>

  <Accordion title="400 word_limit_exceeded">
    `text` is over the 2,000-word limit for a single request. Split the input into smaller chunks and submit them as separate jobs.
  </Accordion>

  <Accordion title="401 unauthorized">
    The `Authorization` header is missing or the key is invalid, expired, revoked, or lacks the `humanize` scope. Confirm you are sending `Authorization: Bearer wib_live_...` and that the key is active in the dashboard. This response carries no rate-limit headers.
  </Accordion>

  <Accordion title="402 insufficient_words">
    Your balance cannot cover the request. The body includes your current `balance` and the `words_required`. Buy more API words, then resend. See [Words and billing](/concepts/words-and-billing).
  </Accordion>

  <Accordion title="404 not_found">
    No job with the given id exists on your account. Check the id, and confirm you are using the same account that submitted the job. Job ids are scoped to the account that created them.
  </Accordion>

  <Accordion title="409 idempotency_conflict">
    You reused an `Idempotency-Key` with a different request body within the 24-hour retention window. The body includes the `existing_job_id` that the key is already bound to and its `expires_at`. Use a fresh key for a new request, or resend the original body. See [Idempotency](/concepts/idempotency).
  </Accordion>

  <Accordion title="413 payload_too_large">
    The request body is over the 128,000-byte limit. This can happen before the word limit is reached if the text contains many multi-byte characters. Reduce the body size and resend.
  </Accordion>

  <Accordion title="429 rate_limited">
    You exceeded the per-key rate limit (20 POST per minute, 120 GET per minute). The body includes `retry_after_seconds` and `reset_at`, and the response carries a `Retry-After` header. Wait, then retry. See [Rate limiting](#rate-limiting).
  </Accordion>

  <Accordion title="429 too_many_concurrent_jobs">
    You have more than 5 active jobs at once. The body includes `active_jobs` and `limit`, and the response carries a `Retry-After` header with a value of `10`. Wait for jobs to finish, then submit again.
  </Accordion>

  <Accordion title="500 enqueue_failed">
    The job was created but could not be enqueued for processing. Reserved words are refunded automatically, so you are not charged. This is transient; retry the submission after a short delay.
  </Accordion>
</AccordionGroup>

## Handling guidance

Sort errors into two groups: those that are safe to retry as-is, and those that need a change to the request before it can succeed.

### Safe to retry

These are transient. Retry the same request after waiting.

* **`429 rate_limited`** and **`429 too_many_concurrent_jobs`** — back off and retry. Honor the `Retry-After` header. See [Rate limiting](#rate-limiting).
* **`500 enqueue_failed`** — the reserved words were refunded, so a retry does not double-charge you. Retry after a short delay.

<Tip>
  When you retry a `POST /humanize`, send the same `Idempotency-Key` you used on the first attempt. A repeated request with an identical body returns the original job instead of creating a duplicate. See [Idempotency](/concepts/idempotency).
</Tip>

### Needs a change

Retrying these without changing the request returns the same error. Fix the cause first.

* **`400 bad_request` / `validation_error` / `word_limit_exceeded`** — correct the body: fix the JSON, the failing field, or split text that is over 2,000 words.
* **`401 unauthorized`** — fix authentication: use an active key with the `humanize` scope.
* **`402 insufficient_words`** — buy more API words, then resend. See [Words and billing](/concepts/words-and-billing).
* **`409 idempotency_conflict`** — use a fresh `Idempotency-Key`, or resend the original body for that key. See [Idempotency](/concepts/idempotency).
* **`404 not_found`** — check the job id and the account.
* **`413 payload_too_large`** — reduce the request body below 128,000 bytes.

## Rate limiting

The API enforces 20 `POST /humanize` requests per minute and 120 `GET /humanize/{id}` requests per minute, per key. A separate limit caps you at 5 active jobs at once.

When you exceed a limit, the response is `429` with a `Retry-After` header giving the number of seconds to wait before retrying. Rate-limited responses also include these headers:

| Header                  | Meaning                                           |
| ----------------------- | ------------------------------------------------- |
| `Retry-After`           | Seconds to wait before the next request.          |
| `X-RateLimit-Limit`     | The request ceiling for the current window.       |
| `X-RateLimit-Remaining` | Requests left in the current window.              |
| `X-RateLimit-Reset`     | When the window resets, as an ISO 8601 timestamp. |

### Recommended backoff

Read `Retry-After` and wait at least that long before retrying. If the header is absent, fall back to exponential backoff with jitter — for example, start at one second and double the delay on each attempt, adding a small random offset so concurrent clients do not retry in lockstep. To stay under the limit in the first place, watch `X-RateLimit-Remaining` and slow down as it approaches zero.

```python Reading rate-limit headers theme={null}
import time

def submit_with_retry(session, payload, max_attempts=5):
    for attempt in range(max_attempts):
        response = session.post(
            "https://www.wibbleai.com/api/v1/humanize",
            headers={"Authorization": "Bearer wib_live_..."},
            json=payload,
        )

        if response.status_code != 429:
            return response

        # Prefer Retry-After; fall back to exponential backoff with jitter.
        retry_after = response.headers.get("Retry-After")
        wait = float(retry_after) if retry_after else min(2 ** attempt, 30) + 0.5
        time.sleep(wait)

    raise RuntimeError("Rate limited after retrying")
```

## Example bodies

<CodeGroup>
  ```json insufficient_words theme={null}
  {
    "error": {
      "code": "insufficient_words",
      "message": "This request needs 240 API words, but the account only has 120 remaining."
    },
    "balance": 120,
    "words_required": 240
  }
  ```

  ```json rate_limited theme={null}
  {
    "error": {
      "code": "rate_limited",
      "message": "Too many API requests. Retry after the reset time."
    },
    "retry_after_seconds": 12,
    "reset_at": "2026-06-14T12:01:00.000Z"
  }
  ```

  ```json too_many_concurrent_jobs theme={null}
  {
    "error": {
      "code": "too_many_concurrent_jobs",
      "message": "This account already has 5 active API jobs. Wait for one to finish before submitting another."
    },
    "active_jobs": 5,
    "limit": 5
  }
  ```
</CodeGroup>

## Related

<Columns cols={2}>
  <Card title="Words and billing" icon="coins" href="/concepts/words-and-billing">
    How reservations, charges, and refunds work, and what `insufficient_words` means for your balance.
  </Card>

  <Card title="Idempotency" icon="rotate" href="/concepts/idempotency">
    Retry submissions safely and avoid `idempotency_conflict`.
  </Card>
</Columns>
