Skip to content

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.