Public REST errors use a stable machine-readable `error.code` and a human-readable `error.message`. Branch on the code or HTTP status, not on message text.

## Canonical errors

| HTTP | Code | Meaning | Retry unchanged? |
| --- | --- | --- | --- |
| 401 | `unauthorized` | Bearer token is missing or invalid | No |
| 402 | `account_inactive` | The account is not active. A prepaid account becomes active with its first credit purchase; a suspended account needs support | No |
| 402 | `quota_exhausted` | The account does not have enough credits for this request (on a legacy subscription: no request quota remains in the period) | No, buy credits first |
| 403 | `forbidden` | The API key is not authorized for this operation | No |
| 422 | `credit_limit_exceeded` | The published job price exceeds the requested `max_credits` | No, raise `max_credits` |
| 422 | `monitor_limit_reached` | The account is at its active monitor limit | No |
| 404 | `not_found` | The requested job does not exist for this account | No |
| 405 | `method_not_allowed` | The endpoint takes another method; the `Allow` header names it (`POST` for `/execute`) | No, change the method |
| 451 | `subject_unavailable` | The input names a profile whose owner asked not to be returned through Upscrape; no job was created | No |
| 409 | `idempotency_conflict` | The key was used for different input | No |
| 422 | `credentials_required` | The capability needs stored upstream credentials | No |
| 422 | `api_key_limit_reached` | The account already has the maximum number of active API keys (25); revoke one first | No |
| 429 | `rate_limited` | The account or key exceeded its current rate | Yes, after delay |
| 500 | `internal_error` | Upscrape encountered an unexpected failure | Usually, with idempotency |
| 500 | `result_unavailable` | The result is unavailable | No; contact support with the job ID |
| 422 | `invalid_input` | The request failed validation; `error.fields` names each field | No, fix the request |
| 503 | `temporarily_unavailable` | The capability cannot be served right now; no job was created | Not immediately; try again later or use another capability |
| 503 | `execution_lost` | The request ended without a result | Yes, submit a new job |

Failed jobs return reviewed public error codes and messages. Diagnostic details are not included.
When `Prefer: wait=N` returns a failed job inline, its HTTP status reflects the known failure class:

| HTTP | Terminal job codes |
| --- | --- |
| 403 | `forbidden`, `login_required`, `auth_failed`, `auth_session_expired` |
| 404 | `not_found` |
| 413 | `payload_too_large` |
| 422 | `invalid_input`, `unsupported_capability`, `incomplete_result` |
| 424 | `upstream_error`, `upstream_http_error` |
| 429 | `upstream_blocked` |
| 503 | `worker_unavailable`, `worker_saturated`, `execution_lost`, `execution_interrupted`, `ocr_unavailable` |
| 504 | `upstream_timeout`, `execution_timeout_exceeded` |

An unrecognized terminal code returns HTTP `500`; HTTP `200` is reserved for completed work and idempotent replay envelopes.

When the source confirms that an account is private, the job fails with `forbidden` and adds `error.reason: "account_private"`. `login_required` means the source asks for sign-in to show this data. Neither changes when retried; treat both as final for that input.

## Validation errors

A request that fails validation returns HTTP `422` with the standard envelope. `error.fields` maps each failed field to its messages, and `error.message` summarises them.

```json
{
  "error": {
    "code": "invalid_input",
    "message": "value must be text",
    "fields": {"input": ["value must be text"]}
  },
  "errors": {"input": ["value must be text"]},
  "request_id": "request_id"
}
```

The top-level `errors` object is the original shape of this response. It is retained for compatibility, carries the same field map as `error.fields`, and will not be removed. New integrations should read `error`, as they do for every other failure.

Validation messages name missing fields, accepted alternatives, expected types, and example values when the input schema provides them. REST, MCP, and the playground use the same explanations.

For failed jobs, `invalid_input` and `not_found` include the scraper's reason when it is safe to display. Otherwise they use a general message. Other job failures use fixed public messages. Source response-format failures and ordinary source HTTP failures remain `upstream_error`.

## Rate limits

HTTP `429` includes a `Retry-After` header in seconds and `error.retry_after_ms` in the JSON body. Wait at least that long and add jitter before retrying.

```json
{
  "error": {
    "code": "rate_limited",
    "message": "rate limit exceeded",
    "retry_after_ms": 1250
  }
}
```

## Safe retry policy

- Retry rate limits after the advertised delay.
- Retry transient transport failures and internal errors only with the original idempotency key.
- Poll an existing pending job instead of resubmitting it.
- If a terminal job reports `execution_lost`, submit a new job; the failed job
  has no stored result and is not charged.
- Do not retry authentication, billing, credential, validation, or idempotency-conflict failures unchanged.
- Put a total deadline and attempt limit around every retry loop.

## Redaction

Job failure messages are sanitized before they enter the public response. Even so, applications should avoid copying entire upstream responses into their own logs without an additional data-sensitivity review.
