API error codes
Every Ssnap API error code, what causes it, and whether the request is worth retrying.
Failures are JSON. The error string is for humans and may be reworded; branch your
code on code, which is stable.
{
"error": "Rate limit exceeded",
"code": "rate_limit_exceeded"
}Error bodies are JSON whatever format you asked for. Check the status code before
writing a response body to disk, or you will save an error object as .png.
All error codes
| Status | code | Cause | Retry? |
|---|---|---|---|
| 401 | missing_api_key | No bearer token and no api_key parameter. | No |
| 401 | invalid_api_key | No key matches the value sent. | No |
| 403 | api_key_inactive | The key has been deactivated. | No |
| 403 | api_key_expired | The key is past its expires_at. | No |
| 402 | subscription_required | The owning team has no active subscription. | No |
| 429 | rate_limit_exceeded | Too many requests this minute for this key. | Yes, after Retry-After |
| 429 | quota_exceeded | The team's monthly allowance is used up. | No |
| 422 | invalid_device | device_id does not match a known preset. | No |
| 422 | invalid_url | The target is not publicly reachable, or did not respond. | No |
| 500 | capture_failed | The render or post-processing failed. | Yes, with backoff |
| 422 | validation errors | A parameter failed validation. | No |
missing_api_key
401. The request carried neither an Authorization: Bearer header nor an api_key
parameter.
The usual cause is a proxy or client library stripping unknown headers. If you cannot
control the headers, the api_key query parameter is equivalent — see
API authentication.
invalid_api_key
401. A key was sent, but no key matches it.
Check for truncation first: keys are long, and copy-paste out of a terminal wraps them. Then check you are not sending a key that has been deleted — Ssnap stores only a SHA-256 hash, so a deleted key is unrecoverable and a new one has to be created.
api_key_inactive
403. The key exists and matches, but has been deactivated in the dashboard.
Reactivate it under API keys, or create a replacement. Deactivation is reversible; deletion is not.
api_key_expired
403. The key is past the expires_at date set when it was created.
Expiry is checked on every request, so this appears the instant the date passes, mid-run if necessary. Create a new key. If your keys keep expiring unexpectedly, rotate on a schedule that is shorter than the expiry, not equal to it.
subscription_required
402. The team that owns the key has no active subscription — either it never had one, or a payment failed.
Captures are gated on an active subscription, so this affects every key in the team at once. Resolve it in the billing portal; see Plans and billing.
rate_limit_exceeded
429. Too many requests in the last rolling 60 seconds for this API key.
This one is temporary. Retry-After gives the seconds until the window resets, usually
under a minute. Every response also carries X-RateLimit-Limit and
X-RateLimit-Remaining, so you can throttle before you get here rather than after.
Cache hits still count against this limit — they cost a request even though they cost no quota. Details in Rate limits and quotas.
quota_exceeded
429. The team's monthly screenshot allowance is used up.
Same status code as the rate limit, opposite handling: this does not clear until the
billing period rolls over, so retrying is pure waste. Branch on code, not on the status.
Three ways out, in order of effort: upgrade the plan, use cache_ttl so repeat captures
stop consuming slots (Screenshot caching), or wait for the period to roll over.
invalid_device
422. device_id does not match any of the 131 presets.
Preset ids are integers and the list is fixed — see Device emulation. If
you only need a specific viewport rather than a specific user agent, drop device_id and
pass width and height instead. A lone width or height is ignored.
invalid_url
422. The target is not a publicly routable http/https address, resolves to a
private or internal address, or did not respond.
The same check applies to callback_url, so a webhook endpoint on localhost or a
private VPC address is refused at request time. Local development therefore needs a
tunnel. Full rules in URL restrictions.
This does not consume quota — the reserved slot is released.
capture_failed
500. The render or the post-processing failed.
The common causes, in rough order of frequency:
- A
selectorthat matched nothing. There is no fallback to the viewport; the render fails. Verify the selector against the live page, and remember it is applied after rendering, so an element created later by JavaScript needsnetwork_idleor adelay. - A timeout. The overall render budget is 30 seconds, and a long
delayeats into it — a 25-second delay on a slow page will time out here. - The page itself crashed the renderer, usually a very large full-page capture.
Worth retrying once or twice with backoff; transient render failures happen. If it persists for a url that loads fine in your own browser, check status and send the full parameter set to support.
Failed captures release their reserved slot, so this does not consume quota.
Validation errors
422, but a different shape. Parameter validation runs before any browser work, and returns Laravel's standard body:
{
"message": "The selector field must not be greater than 255 characters.",
"errors": {
"selector": ["The selector field must not be greater than 255 characters."]
}
}The errors map is keyed by parameter name. There is no code field here — the presence
of errors is how you tell the two kinds of 422 apart:
const body = await response.json();
const reason = body.code ?? Object.keys(body.errors ?? {}).join(', ');Ranges and types for every parameter are in Capture parameters.
Two 429s, two meanings
Worth restating because it is the single most common integration bug:
rate_limit_exceededis temporary. WaitRetry-Afterand retry.quota_exceededlasts until the billing period rolls over. Retrying will not help, and the retries themselves burn your rate limit.
Quota is not charged for failures
A capture that fails mid-render releases its reserved slot, so capture_failed and
invalid_url do not consume quota. Cache hits do not consume quota either.
Async errors
When callback_url is set, the HTTP call returns 202 and any error is delivered to
your webhook instead of in the response:
{ "status": "error", "error": "Invalid url", "code": "invalid_url" }The code values are identical to the table above. This includes the quota and
subscription checks, which run when the job executes, not when it is queued — so a
request that returned 202 can still end in quota_exceeded. See
Async callbacks.
Retry guidance
| Code | Retry? |
|---|---|
capture_failed | Yes, once or twice, with backoff. Transient render failures happen. |
rate_limit_exceeded | Yes, after Retry-After. |
quota_exceeded | No, not until the billing period rolls over. |
invalid_url, invalid_device, validation | No, fix the request. |
missing_api_key, invalid_api_key, api_key_*, subscription_required | No, fix credentials or billing. |