Docs menu

Errors

The error envelope, request IDs, what to retry, and every error code with its HTTP status.

View as Markdown

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

{
  "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:

curl

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)"]}'

Node.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);
}

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

StatusMeaningWhat to do
400, 404, 405, 413, 415, 422The request is wrong for this resourceFix the request; retrying the same one fails the same way
401, 403Credential missing or not allowedCheck the key, its environment and scopes (details.reason)
402Not enough credits; nothing was createdTop up (details.topUpUrl), then retry with the same Idempotency-Key
409Conflicts with the current stateRead the resource again (details.status, details.action) and decide
429Rate, concurrency or daily limitWait Retry-After seconds, then retry (Rate limits)
500, 503Server side or busyRetry 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. Reserved codes are part of the contract but not sent yet.

CodeHTTPStateMeaning
INVALID_INPUT400activeInvalid input. The request failed validation or its body could not be parsed. details.issues lists each problem as { location, path, message }.
UNAUTHORIZED401activeUnauthorized. No valid credential. Send Authorization: Bearer <API key or access token>. Missing, malformed, unknown, revoked and expired credentials all return this same body.
FORBIDDEN403activeForbidden. 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).
NOT_FOUND404activeNot found. No /v1 endpoint exists at this path (details echoes { method, path }), or the addressed resource does not exist for this caller.
METHOD_NOT_ALLOWED405activeMethod not allowed. The path exists but does not support this HTTP method. See the Allow header and details.allowedMethods.
PAYLOAD_TOO_LARGE413activePayload too large. The request body exceeds the endpoint limit. details.maxBytes gives the limit.
UPLOAD_NOT_READY409activeUpload 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).
IDEMPOTENCY_CONFLICT409activeIdempotency 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.
GENERATION_STATE_CONFLICT409activeGeneration 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).
SCREENSHOT_SET_STATE_CONFLICT409activeScreenshot 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).
WEBHOOK_STATE_CONFLICT409activeWebhook 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).
BILLING_STATE_CONFLICT409activeBilling 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).
IMAGE_TOO_LARGE413activeImage 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).
UNSUPPORTED_FORMAT415activeUnsupported 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).
NOT_A_SCREENSHOT422activeNot 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.
URL_FETCH_FAILED422activeURL 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.
WEBHOOK_URL_REJECTED422activeWebhook 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.
INSUFFICIENT_CREDITS402activeInsufficient 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.
RATE_LIMITED429activeRate 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.
CONCURRENCY_LIMITED429activeConcurrency 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).
INTERNAL_ERROR500activeInternal error. An unexpected error occurred. Quote the requestId when contacting support.
GENERATION_FAILED502activeGeneration 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.
SERVICE_UNAVAILABLE503activeService unavailable. A dependency is unavailable (on /v1/health, details contains the health report), or uploads are busy (Retry-After): retry after that many seconds.
BILLING_UNAVAILABLE503activeBilling 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).
GENERATION_TIMEOUT504activeGeneration 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.