# Errors and retries

> Stable machine codes, what each one means, and which requests are safe to send again.

Every failure has the same shape:

```json
{
  "error": {
    "code": "insufficient_scope",
    "message": "This key is missing the `render.write` scope. Re-mint it with that scope at developer.vidmoat.com.",
    "docs_url": "https://developer.vidmoat.com/developer/docs/errors#insufficient_scope",
    "scope": "render.write",
    "granted": ["account.read", "projects.read"]
  }
}
```

Branch on `code`, never on `message`. The code is part of the contract; the message is written for a person reading a log and gets reworded whenever a clearer sentence turns up. Some codes add detail fields next to it (`scope`, `granted`, `remaining`, `restriction`, `field`, `conflictCode`, `rev`). New codes can appear in `v1`: treat an unknown one as a generic failure of its HTTP status rather than crashing.

## Codes

| HTTP | Code | Retry? |
| --- | --- | --- |
| 400 (409) | [`invalid_request`](#invalid_request) | No; fix the request. For 409, re-read the project and send again. |
| 401 | [`unauthorized`](#unauthorized) | No, until the credential is fixed. |
| 402 | [`plan_required`](#plan_required) | No; upgrade the plan. |
| 402 | [`insufficient_credits`](#insufficient_credits) | After topping up. |
| 402 (413) | [`quota_exceeded`](#quota_exceeded) | When the allowance frees up (a render finishes, the month resets). |
| 403 | [`insufficient_scope`](#insufficient_scope) | No; mint a key with that scope. |
| 403 | [`access_suspended`](#access_suspended) | No. |
| 403 | [`app_suspended`](#app_suspended) | No; check the contact address on the app. |
| 404 | [`not_found`](#not_found) | No. |
| 413 | [`payload_too_large`](#payload_too_large) | No; import from a URL or use a smaller file. |
| 422 | [`prompt_rejected`](#prompt_rejected) | No; reword it. |
| 429 | [`rate_limited`](#rate_limited) | Yes, after `Retry-After` seconds. |
| 500 | [`internal_error`](#internal_error) | Once, with backoff, after re-reading state. |
| 501 | [`not_configured`](#not_configured) | Later. |
| 502 | [`provider_error`](#provider_error) | Yes, with backoff. |
| 503 | [`unavailable`](#unavailable) | Yes, after `Retry-After` (30 seconds). |

> **Warning: 402 is three different problems.** `insufficient_credits` means top up; `plan_required` means the feature is not on the plan at all and no amount of credit will unlock it; `quota_exceeded` means an allowance is used up for now. They are separate codes so your code can tell the user which.

> **Note: 404 hides 403.** A project you do not own returns `not_found`, not a permission error. Otherwise the API would confirm the existence of other people's projects to anybody who guessed an id.

### `invalid_request`

**400 (409).** The request is malformed: a missing or wrong-typed field, a body that is not a JSON object, a bad command list. Also sent with status **409** when a project changed while your commands ran (`conflictCode: "stale_rev"`, with the current `rev`). **Retry:** No; fix the request. For 409, re-read the project and send again.

### `unauthorized`

**401.** No bearer token, or one that is unknown, revoked or expired. **Retry:** No, until the credential is fixed.

### `plan_required`

**402.** The owner's plan does not include this feature. The scope was necessary but not sufficient. **Retry:** No; upgrade the plan.

### `insufficient_credits`

**402.** Not enough credits for this call. Nothing ran and nothing was charged. `remaining` is the balance. Also used when video generation is not available on a trial or a full-waiver promotion. **Retry:** After topping up.

### `quota_exceeded`

**402 (413).** An allowance is used up: the plan's project limit, monthly exports, renders running at once, one video job at a time. Sent with **413** when cloud storage is full. **Retry:** When the allowance frees up (a render finishes, the month resets).

### `insufficient_scope`

**403.** The credential is valid but lacks a scope. `scope` names the one needed and `granted` lists what the credential has. **Retry:** No; mint a key with that scope.

### `access_suspended`

**403.** The owning account is restricted. `restriction` says what is limited, until when, and links to the appeal. **Retry:** No.

### `app_suspended`

**403.** This app's kill switch is on, independent of the account's standing. **Retry:** No; check the contact address on the app.

### `not_found`

**404.** No such resource, or it belongs to somebody else. The two are deliberately indistinguishable. **Retry:** No.

### `payload_too_large`

**413.** An upload is over 32 MB or over the plan's upload cap, or an import is over the import limit. **Retry:** No; import from a URL or use a smaller file.

### `prompt_rejected`

**422.** A prompt failed the safety check. `field` names the field. Nothing was charged. **Retry:** No; reword it.

### `rate_limited`

**429.** Too many requests in the window. Refused requests do not count against the limit. **Retry:** Yes, after `Retry-After` seconds.

### `internal_error`

**500.** Our fault. Nothing is implied about whether the work happened. **Retry:** Once, with backoff, after re-reading state.

### `not_configured`

**501.** A provider this call needs (speech-to-text, a stock library) is not available. Nothing was charged. **Retry:** Later.

### `provider_error`

**502.** An upstream failed: a generation provider, a URL you asked us to import, a plugin, a frame render. A charged call is refunded. **Retry:** Yes, with backoff.

### `unavailable`

**503.** The rate limiter could not be reached, so the request was refused without running or charging anything. **Retry:** Yes, after `Retry-After` (30 seconds).

## Retrying safely

There is no `Idempotency-Key` header. Decide what to retry by what the call does:

| Request | After a timeout or a 5xx |
| --- | --- |
| Any `GET` | Safe to retry. |
| `PATCH` and `PUT` | Safe to retry: they set state, so repeating one gives the same result. |
| `DELETE` | Safe; a second call answers `404`. |
| `POST /v1/projects/{id}/commands` | **Not safe.** A repeated batch adds its clips again. Re-read the project first. |
| `POST /v1/projects` | **Not safe.** It creates a second project. List projects first. |
| `POST /v1/renders` | **Not safe.** It queues a second render and uses another export. List `GET /v1/renders?projectId=…` first. |
| Generation (`/v1/ai/*`) | **Not safe.** A repeat is charged again. A failure that was our fault (`502`) is refunded, so retrying one of those is fine. |

Back off exponentially (1, 2, 4, 8 seconds, with jitter) and give up after a few attempts. Honour `Retry-After` when it is present.

## Rate limits

Limits are per key, per minute, by plan. See [rate limits](https://developer.vidmoat.com/developer/docs/rate-limits).

---

Source: https://developer.vidmoat.com/developer/docs/errors
Previous: [Editor starter](https://developer.vidmoat.com/developer/docs/editor-starter.md)
Next: [Rate limits](https://developer.vidmoat.com/developer/docs/rate-limits.md)
All documentation: https://developer.vidmoat.com/llms-full.txt
