Errors & limits¶
Error shape¶
Every error is JSON, same shape everywhere:
{
"error": {
"code": "invalid_operations",
"message": "resize.fit must be one of: inside, outside, cover, contain, fill."
}
}
Some errors include extra fields alongside code/message — e.g.
quota_exceeded includes upgrade_url.
Status codes — POST /v1/image¶
| Status | Code | When |
|---|---|---|
| 401 | missing_api_key |
No Authorization header, or not in Bearer <key> form. |
| 401 | invalid_api_key |
The key doesn't match any active account. |
| 400 | invalid_operations |
The operations JSON is invalid — unknown key, bad value, failed to parse, unsupported format, etc. |
| 400 | no_image |
Neither an image file nor a url was provided. |
| 400 | bad_upload |
Multer rejected the upload for a reason other than size (e.g. malformed multipart body). |
| 413 | image_too_large |
The image is too large — an uploaded file over the max upload size, a URL-fetched image over the fetch size cap, or a decoded image over the max pixel count. |
| 400 | bad_url |
The url value isn't a valid URL. |
| 400 | bad_url_scheme |
The url isn't http:// or https://. |
| 400 | blocked_url |
The URL resolves to a private/internal/disallowed address — see below. |
| 400 | too_many_redirects |
The URL redirected more times than allowed. |
| 400 | fetch_failed |
Fetching the URL failed (non-200 response, connection error). |
| 400 | fetch_timeout |
Fetching the URL took too long. |
| 503 | fetch_busy |
Too many concurrent URL fetches in flight; retry shortly. Response includes Retry-After: 2. |
| 503 | busy |
The server is at its processing-concurrency limit; retry shortly. Response includes Retry-After: 2. |
| 422 | process_timeout |
Processing didn't finish inside the per-request deadline. |
| 422 | unprocessable_image |
The image is corrupt or an unsupported format for processing. |
| 429 | quota_exceeded |
You've used your plan's monthly quota. See below. |
| 500 | internal_error |
Unhandled server error. |
Quota and 429¶
Each key belongs to a plan tier with a monthly image quota. A request that would exceed it is rejected before any processing happens, so it doesn't count against you:
HTTP/1.1 429 Too Many Requests
X-Purlo-Quota-Limit: 1000
X-Purlo-Quota-Remaining: 0
Content-Type: application/json
{
"error": {
"code": "quota_exceeded",
"message": "You have used all 1000 images on your free plan this month.",
"upgrade_url": "https://purlo.dev/pricing"
}
}
Quota only counts successful requests — if a request fails for any other reason (bad input, processing error, timeout, server busy), the reserved unit is refunded and doesn't count against your quota.
Indicative monthly quotas by tier (tune-able; confirm current values against your account):
| Tier | Quota |
|---|---|
| Free | 1,000 images/month |
| Starter | 5,000 images/month |
| Growth | 25,000 images/month |
| Scale | 100,000 images/month |
blocked_url¶
When you pass a url instead of uploading a file, Purlo fetches it — but only
from ordinary public internet addresses. Requests are rejected with
blocked_url if the URL (or anything it redirects to) resolves to:
- loopback (
127.0.0.1,::1) - private ranges (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16, IPv6 unique-local) - link-local addresses, including cloud metadata endpoints (
169.254.169.254) - multicast, unspecified, or other reserved/non-unicast ranges
- IPv4-mapped/embedded IPv6 forms of any of the above (
::ffff:127.0.0.1, 6to4, NAT64, Teredo)
This is an SSRF guard: it stops your Purlo request from being used to probe
your own (or anyone else's) internal network. Every redirect hop is
re-validated the same way, and DNS is resolved once and pinned to the address
that was checked, closing the DNS-rebinding gap. If you get blocked_url on
a URL you expect to work, check where it (or its redirects) actually resolve.
Resource limits¶
Defaults below (all configurable server-side via environment variables — the values here are the shipped defaults; check with your Purlo operator if you're self-hosting with overrides):
| Limit | Default | Applies to |
|---|---|---|
| Max upload size | 10 MB | image file upload |
| Max fetched size | 10 MB | url fetch |
| Max decoded pixels | ~40 MP (e.g. 8000×5000) | Both — decompression-bomb guard |
| Max resize dimension | 10,000 px | resize.w / resize.h |
| Processing deadline | 15 s | Per request |
| Fetch timeout | 10 s | Per url fetch, per hop |
| Max redirects | 3 | url fetch |
| Max concurrent processing jobs | 2 | Server-wide — beyond this, new requests get 503 busy |
| Max concurrent URL fetches | 4 | Server-wide — beyond this, new fetch requests get 503 fetch_busy |
Both 503 responses include a Retry-After: 2 header — back off and retry.
An oversized image returns 413 image_too_large regardless of whether it was
uploaded or fetched from a URL — branch on error.code and you're safe either way.
Rate limits¶
Purlo's primary throttle is the monthly quota per tier (above), enforced
per API key, plus the concurrency limits above that protect the server
under load (503 busy / 503 fetch_busy). There is no separate
short-window (e.g. per-second) rate limit documented beyond these two
mechanisms in the current implementation — if that changes, it'll be
reflected here with its own status code and headers.