# Errors

> HTML version: https://storeshot.art/docs/errors/

Every error of the API has the same JSON body, whatever the endpoint:

```json
{
  "error": {
    "code": "INVALID_INPUT",
    "message": "Request validation failed.",
    "requestId": "1a1ae233-21d0-4d7a-9549-d4c6b482f2f3",
    "docUrl": "https://api.storeshot.art/v1/docs/errors#INVALID_INPUT",
    "details": { "issues": [{ "location": "body", "path": ["app"], "message": "Required" }] }
  }
}
```

- `code` is stable (UPPER_SNAKE): branch on it, never on `message`, which may change.
- `requestId` equals the `X-Request-Id` response header. Quote it when you contact support.
- `details` is optional and specific to the code (validation issues, the required scope, the
  credits needed, the seconds to wait); the catalogue below says what each code carries.

## Handle an error

This request leaves out required fields, so it answers `400 INVALID_INPUT` with one entry per
problem in `details.issues`:

<!-- tabs -->
<!-- doctest: expect=400 -->
```sh
curl -sS https://api.storeshot.art/v1/generations \
  -H "Authorization: Bearer $STORESHOT_API_KEY" -H 'Content-Type: application/json' \
  -d '{"inputs":[],"store":"app_store"}' \
  | jq '{code: .error.code, requestId: .error.requestId, issues: [.error.details.issues[] | "\(.path | join(".")): \(.message)"]}'
```

<!-- doctest: expect=400 -->
```js
const res = await fetch('https://api.storeshot.art/v1/generations', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.STORESHOT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ inputs: [], store: 'app_store' }),
});
if (!res.ok) {
  const { error } = await res.json();
  console.log(res.status, error.code, error.requestId);
  for (const issue of error.details?.issues ?? []) console.log(issue.path.join('.'), issue.message);
}
```

<!-- doctest: expect=400 -->
```python
import os, requests

res = requests.post(
    "https://api.storeshot.art/v1/generations",
    headers={"Authorization": f"Bearer {os.environ['STORESHOT_API_KEY']}"},
    json={"inputs": [], "store": "app_store"},
)
if not res.ok:
    error = res.json()["error"]
    print(res.status_code, error["code"], error["requestId"])
    for issue in error.get("details", {}).get("issues", []):
        print(".".join(str(p) for p in issue["path"]), issue["message"])
```

## What to retry

| Status | Meaning | What to do |
|---|---|---|
| `400`, `404`, `405`, `413`, `415`, `422` | The request is wrong for this resource | Fix the request; retrying the same one fails the same way |
| `401`, `403` | Credential missing or not allowed | Check the key, its environment and scopes (`details.reason`) |
| `402` | Not enough credits; nothing was created | Top up (`details.topUpUrl`), then retry with the same `Idempotency-Key` |
| `409` | Conflicts with the current state | Read the resource again (`details.status`, `details.action`) and decide |
| `429` | Rate, concurrency or daily limit | Wait `Retry-After` seconds, then retry ([Rate limits](/docs/rate-limits/)) |
| `500`, `503` | Server side or busy | Retry with exponential backoff (and `Retry-After` when sent), same `Idempotency-Key` |

Requests that create something (`POST /generations`, `POST /screenshot-sets`) are safe to retry
with the same `Idempotency-Key`: a request that already succeeded is answered again instead of
running twice. A generation that ends `failed` reports its reason in its own `error.code`
(`GENERATION_FAILED`, `GENERATION_TIMEOUT`, …) and, with a live key, is refunded; create a new
generation to try again.

## Error codes

Every code has its own anchor on this page, for example
[`/docs/errors/#RATE_LIMITED`](/docs/errors/#RATE_LIMITED). Reserved codes are part of the
contract but not sent yet.

| Code | HTTP | State | Meaning |
|---|---|---|---|
| <a id="INVALID_INPUT"></a>`INVALID_INPUT` | 400 | active | **Invalid input.** The request failed validation or its body could not be parsed. `details.issues` lists each problem as `{ location, path, message }`. |
| <a id="UNAUTHORIZED"></a>`UNAUTHORIZED` | 401 | active | **Unauthorized.** No valid credential. Send `Authorization: Bearer <API key or access token>`. Missing, malformed, unknown, revoked and expired credentials all return this same body. |
| <a id="FORBIDDEN"></a>`FORBIDDEN` | 403 | active | **Forbidden.** The credential is valid but may not do this. `details.reason` is one of `insufficient_scope` (with `details.requiredScope`, and `details.requiredScopes` when any of several scopes is accepted), `api_key_limit_reached` (with `details.limit`), `scope_not_grantable`, `environment_not_grantable`, `account_conflict`, `upload_quota_exceeded` (with `details.kind` `count` or `bytes` and `details.limit`; delete uploads to free space), `webhook_limit_reached` (with `details.limit`, webhooks per environment), `project_limit_reached` (with `details.limit`, projects per account), `style_limit_reached` (with `details.limit`, styles per account), `live_key_required` (billing checkouts need a live key or an access token; test keys never buy) or `billing_account_flagged` (a refund left the account short of credits it had already used; contact support before buying again). |
| <a id="NOT_FOUND"></a>`NOT_FOUND` | 404 | active | **Not found.** No `/v1` endpoint exists at this path (`details` echoes `{ method, path }`), or the addressed resource does not exist for this caller. |
| <a id="METHOD_NOT_ALLOWED"></a>`METHOD_NOT_ALLOWED` | 405 | active | **Method not allowed.** The path exists but does not support this HTTP method. See the `Allow` header and `details.allowedMethods`. |
| <a id="PAYLOAD_TOO_LARGE"></a>`PAYLOAD_TOO_LARGE` | 413 | active | **Payload too large.** The request body exceeds the endpoint limit. `details.maxBytes` gives the limit. |
| <a id="UPLOAD_NOT_READY"></a>`UPLOAD_NOT_READY` | 409 | active | **Upload not ready.** The upload cannot be used or completed in its current state. `details.reason` is `object_missing` (no file was uploaded to the presigned URL yet: upload it, then retry; the upload stays pending), `expired` (the upload expired before it was completed), `failed` (with `details.errorCode`) or `deleted`. `details.path` names the field; for a screenshot set retry it can be `["styleId"]` (the set's copy of its brand kit logo has expired). |
| <a id="IDEMPOTENCY_CONFLICT"></a>`IDEMPOTENCY_CONFLICT` | 409 | active | **Idempotency conflict.** This `Idempotency-Key` was already used by this account for a different request (another body, or a credential of the other environment). `details.generationId` is the generation created with it (`details.screenshotSetId` for `POST /screenshot-sets`, `details.styleId` for `POST /styles`, `details.checkoutId` for `POST /billing/checkout`, also while that checkout is still being created). Use a new key for a new request. |
| <a id="GENERATION_STATE_CONFLICT"></a>`GENERATION_STATE_CONFLICT` | 409 | active | **Generation state conflict.** The generation cannot do this in its current state. `details.status` is its status and `details.action` is `cancel` (only a generation that has not started yet can be canceled) or `delete` (only a finished generation can be deleted). |
| <a id="SCREENSHOT_SET_STATE_CONFLICT"></a>`SCREENSHOT_SET_STATE_CONFLICT` | 409 | active | **Screenshot set state conflict.** The screenshot set cannot do this in its current state. `details.status` is the set status and `details.action` is `retry` (no image is failed or canceled, or every one used its 5 attempts; nothing was charged), `cancel` (the set already finished), `delete` (images are still queued or running: cancel the set or wait) or `archive` (no image has succeeded yet). |
| <a id="WEBHOOK_STATE_CONFLICT"></a>`WEBHOOK_STATE_CONFLICT` | 409 | active | **Webhook state conflict.** The webhook cannot do this in its current state. `details.action` is `test` (a disabled webhook receives no test event: enable it first). |
| <a id="BILLING_STATE_CONFLICT"></a>`BILLING_STATE_CONFLICT` | 409 | active | **Billing state conflict.** The plan cannot do this in its current state; nothing was charged. `details.reason` is `subscription_exists` (the account already has a plan: change it instead), `checkout_pending` (a plan checkout is still open: finish it, or retry later), `no_subscription` (the account has no plan), `same_plan` (already on that plan), `canceling` (the plan ends at the period end: uncancel it first), `past_due` (a payment is overdue: update the payment method in the portal) or `no_customer` (no payment account is linked to this account yet). |
| <a id="IMAGE_TOO_LARGE"></a>`IMAGE_TOO_LARGE` | 413 | active | **Image too large.** An uploaded image is too large. `details.reason` is `bytes` (the file exceeds `details.maxBytes`, 20 MB) or `pixels` (more than `details.maxPixels` pixels, 50 megapixels, or an edge longer than `details.maxEdge`, 16384 pixels). |
| <a id="UNSUPPORTED_FORMAT"></a>`UNSUPPORTED_FORMAT` | 415 | active | **Unsupported format.** An uploaded file is not a PNG, JPEG or WebP image (checked from its bytes, never from its name or declared type). `details.reason` is `unsupported_type`, `heic` (HEIC/HEIF photos are not supported; export as PNG or JPEG), `corrupt` (the image could not be decoded) or `type_mismatch` (the uploaded file is not the `contentType` declared when presigning). |
| <a id="NOT_A_SCREENSHOT"></a>`NOT_A_SCREENSHOT` | 422 | active | **Not a screenshot.** The input image does not look like an app screenshot: its size or shape is not a screen (checked when you create the generation; `details.path` names the input), or it carries camera capture data such as an exposure time (checked before any image is generated; the generation fails with this `error.code` and its credit is refunded). Upload a screenshot taken on the device. |
| <a id="URL_FETCH_FAILED"></a>`URL_FETCH_FAILED` | 422 | active | **URL fetch failed.** The image URL could not be fetched. Only `https://` URLs on port 443 that resolve to public addresses are fetched, with at most 3 redirects and 15 seconds in total. `details.reason` is `blocked_destination`, `dns_failure`, `connect_failed`, `tls_error`, `timeout`, `too_many_redirects`, `insecure_redirect` (a redirect to a non-https URL), `http_status` (with `details.status`) or `empty_body`. |
| <a id="WEBHOOK_URL_REJECTED"></a>`WEBHOOK_URL_REJECTED` | 422 | active | **Webhook URL rejected.** The webhook URL is well formed but cannot be used. `details.reason` is `blocked_destination` (the host is or resolves to a private, loopback, link-local or otherwise non-public address; every DNS answer must be public) or `dns_failure` (the host does not resolve). The same check runs again before every delivery. |
| <a id="INSUFFICIENT_CREDITS"></a>`INSUFFICIENT_CREDITS` | 402 | active | **Insufficient credits.** The account does not have enough credits for this operation; nothing was created or charged. `details.requiredCredits` is the cost, `details.availableCredits` the current balance and `details.topUpUrl` where to get more credits. Test keys (`ss_test_…`) never need credits. |
| <a id="RATE_LIMITED"></a>`RATE_LIMITED` | 429 | active | **Rate limited.** Too many requests. Retry after the number of seconds in the `Retry-After` header (`details.retryAfterSeconds`). `details.scope` is `key` or `user` and `details.policy` is `requests` (per-minute request rate of the API key or of the whole account, with `details.limit` and `details.windowSeconds`; see the `RateLimit` headers) `daily_generations` (the account reached its generations for this UTC day, `details.limit`), `webhook_tests` (test events of one webhook per hour, `details.limit`) or `billing_checkouts` (checkouts created by the account in the last hour, `details.limit`). Without `details.scope`, too many failed authentication attempts came from this IP. |
| <a id="CONCURRENCY_LIMITED"></a>`CONCURRENCY_LIMITED` | 429 | active | **Concurrency limited.** Too many generations of this account are in progress. Retry after the number of seconds in the `Retry-After` header (`details.retryAfterSeconds`); `details.limit` is the number allowed at once (`details.scope` `user`, `details.policy` `concurrent_generations`). |
| <a id="INTERNAL_ERROR"></a>`INTERNAL_ERROR` | 500 | active | **Internal error.** An unexpected error occurred. Quote the `requestId` when contacting support. |
| <a id="GENERATION_FAILED"></a>`GENERATION_FAILED` | 502 | active | **Generation failed.** Appears as the `error.code` of a failed generation (never as an HTTP status): the image could not be generated. Its credit was refunded; create a new generation to try again. |
| <a id="SERVICE_UNAVAILABLE"></a>`SERVICE_UNAVAILABLE` | 503 | active | **Service unavailable.** A dependency is unavailable (on `/v1/health`, `details` contains the health report), or uploads are busy (`Retry-After`): retry after that many seconds. |
| <a id="BILLING_UNAVAILABLE"></a>`BILLING_UNAVAILABLE` | 503 | active | **Billing unavailable.** Credits cannot be bought right now; nothing was charged. `details.reason` is `disabled` (web payments are switched off), `not_configured` (payments are not set up on this server), `provider_error` (the payment provider did not answer; retry later, with the same `Idempotency-Key` if you sent one) or `webhook_not_configured` (payment notifications are not accepted yet). |
| <a id="GENERATION_TIMEOUT"></a>`GENERATION_TIMEOUT` | 504 | active | **Generation timeout.** Appears as the `error.code` of a failed generation (never as an HTTP status): generating the image took too long. Its credit was refunded; create a new generation to try again. |
