Try it
Run a real evaluation against the live API, right here — no API key needed on your end. This uses a public demo key on a small shared quota, so it's for getting a feel of the response, not for real integration testing.
Submitted as originalUrl — we derive our own preview from it server-side, there's no separate preview URL to provide. Must respond 200 directly (no redirects) and serve JPEG/PNG/TIFF/BMP/HEIC. Demo quota is small and shared by every visitor — if it's exhausted, try again shortly, or use your own key (see Getting Started).
Authentication
Every request requires an x-api-key header. Keys are issued manually — contact us to get one; there's no self-serve signup.
x-api-key: <your-api-key>
A missing or invalid key returns 403 Forbidden.
Rate limits
Third-party keys are metered:
- 5 requests/second, burst 10
- 1,000 requests/month
Contact us if you need a higher limit.
Getting Started
A minimal path from zero to your first evaluation result.
1. Get an API key
Keys are issued manually — contact us to request one. There's no self-serve signup.
2. Submit your first evaluation
This example is runnable as-is — it points at a public test image, so you can copy/paste it and get a real result without hosting anything yourself first.
curl -X POST https://api.vonango.com/evaluations \ -H "x-api-key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "originalUrl": "https://httpbin.org/image/jpeg" }'
{ "evaluationId": "07fc0511-e795-4cad-919f-e661c316a19c", "status": "processing" }
Save the evaluationId — you'll need it to poll for the result.
3. Poll for the result
curl https://api.vonango.com/evaluations/07fc0511-e795-4cad-919f-e661c316a19c \ -H "x-api-key: $API_KEY"
Repeat every 3–5 seconds until status is no longer "processing". Most evaluations complete in under a minute.
4. Read the result
When status is "completed", the result field contains contentAnalysis (what the AI sees) and preprocessing (measured metadata, color, and sharpness) — see The result object below for the full field reference. When status is "failed", see Troubleshooting below.
Same flow, in code
import requests import time API_KEY = "your-api-key" BASE_URL = "https://api.vonango.com" def evaluate(original_url): response = requests.post( f"{BASE_URL}/evaluations", headers={"x-api-key": API_KEY}, json={"originalUrl": original_url}, ) response.raise_for_status() evaluation_id = response.json()["evaluationId"] while True: time.sleep(3) result = requests.get( f"{BASE_URL}/evaluations/{evaluation_id}", headers={"x-api-key": API_KEY}, ).json() if result["status"] != "processing": return result result = evaluate("https://httpbin.org/image/jpeg") print(result)
const API_KEY = "your-api-key"; const BASE_URL = "https://api.vonango.com"; async function evaluate(originalUrl) { const submit = await fetch(`${BASE_URL}/evaluations`, { method: "POST", headers: { "x-api-key": API_KEY, "Content-Type": "application/json" }, body: JSON.stringify({ originalUrl }), }); const { evaluationId } = await submit.json(); while (true) { await new Promise((r) => setTimeout(r, 3000)); const poll = await fetch(`${BASE_URL}/evaluations/${evaluationId}`, { headers: { "x-api-key": API_KEY }, }); const result = await poll.json(); if (result.status !== "processing") return result; } } const result = await evaluate("https://httpbin.org/image/jpeg"); console.log(result);
Troubleshooting
Common reasons an evaluation ends in status: "failed", and what to do about each:
| Symptom | Cause | Fix |
|---|---|---|
IngestException — redirect |
Redirects aren't followed | Host the file at a URL that responds 200 directly |
IngestException — 403 / blocked |
Some hosts block non-browser requests by User-Agent |
Host the file somewhere that serves plain HTTP clients — most object storage (S3, GCS, etc.) works fine |
IngestException — private IP rejected |
SSRF protection — private, loopback, or link-local IPs are always rejected | Use a publicly resolvable URL |
IngestException — unsupported format |
The file's actual bytes don't match an accepted format — checked by content, not extension or Content-Type |
Confirm the file is genuinely JPEG/PNG/TIFF/BMP/HEIC |
IngestException — size/timeout exceeded |
Original over 1.5GB, or the fetch took too long | Reduce file size — there's no retry, so a slow host fails the whole evaluation |
IngestException — content rejected |
Failed content moderation at the requested contentPolicy tier — see Content moderation |
Use a different image, or a more permissive contentPolicy if appropriate. Some content is rejected at every tier |
Stuck on "processing" past a minute |
Uncommon | Contact us with the evaluationId |
A 403 Forbidden on the request itself — before you ever get an evaluationId — means the x-api-key header is missing or invalid, including on /health.
Endpoints
Liveness check. Returns 200 { "status": "ok" }. Still requires x-api-key.
Starts an evaluation. Processing is asynchronous — this returns immediately with an ID to poll.
{
"originalUrl": "https://example.com/original.jpg",
"services": ["upscale"],
"contentPolicy": "strict"
}
| Field | Required | Notes |
|---|---|---|
originalUrl |
yes | The full-resolution file. Used for metadata/color/sharpness analysis, and as the source we derive a preview from for AI content analysis and content moderation. |
services |
no | Optional add-on services to run alongside the core evaluation. Omit entirely for core-evaluation-only (the default, unchanged behavior). Currently supported: "upscale" (basic 4x upscale, Real-ESRGAN) and "upscale-hd" (sharper 4x upscale, Topaz) — two tiers of the same capability, see the upscale field reference. An unrecognized value returns 400. |
contentPolicy |
no | Content-moderation tier — "strict" (default), "moderate", or "permissive". See Content moderation below. An unrecognized value returns 400. |
originalUrl must- Use
https - Respond
200directly — redirects are not followed - Be reachable by a non-browser HTTP client (some hosts block server-side requests by User-Agent)
- Not resolve to a private, loopback, or link-local IP address
- Serve JPEG, PNG, TIFF, BMP, or HEIC — detected from file bytes, not the
Content-Typeheader
Size/time limits: ≤ 1.5GB / 10min fetch timeout. No retry — if the fetch fails, the evaluation fails.
There is deliberately no separate previewUrl field — we derive our own preview from originalUrl rather than trusting a second, independently-hosted file. This also means content moderation can never be evaded by pointing a preview and an original at different content.
{
"evaluationId": "07fc0511-e795-4cad-919f-e661c316a19c",
"status": "processing"
}
Errors: 400 invalid_request (missing/malformed field or URL), 500 internal_error.
Polls for the result. Always 200 once the ID exists — the evaluation's own status field tells you whether to keep polling.
| status | Body |
|---|---|
processing |
{ evaluationId, status } — poll again shortly |
completed |
{ evaluationId, status, result } — see below |
failed |
{ evaluationId, status, error: { type, cause } } |
Errors: 404 not_found (unknown evaluationId), 500 internal_error. Recommended polling interval: every 3–5 seconds; typical completion is well under a minute.
Content moderation
Every submission is checked against Amazon Rekognition's content moderation before anything else runs — the check happens first, gating the whole pipeline, so rejected content is never analyzed, upscaled, or stored beyond the rejection itself.
The contentPolicy request field controls how much nudity is tolerated:
| contentPolicy | Allows |
|---|---|
"strict" (default) |
Swimwear/underwear-level content. No nudity beyond that. |
"moderate" |
Partial/suggestive nudity. Not full nudity. |
"permissive" |
Full nudity. |
Regardless of contentPolicy, two things are never permitted and cannot be resubmitted at any tier:
- Graphic sexual acts.
- Any apparent sexualized depiction of a minor. Content flagged for this is quarantined, not simply rejected.
If content is rejected, the evaluation ends in status: "failed" (the same shape as any other ingest-time failure — see Troubleshooting), not a 4xx on the submit request itself, since the URL/request were well-formed and the rejection only becomes known after fetching and inspecting the image.
A note on the age-based check specifically: this uses an automated age estimate (Amazon Rekognition's DetectFaces) as a triage signal, not a definitive determination — it only fires on a detectable, mostly-frontal face, and estimated ages carry real error margins. It is intentionally conservative in the direction of over-flagging rather than under-flagging.
The result object
Present when status: "completed". Two top-level parts always present: contentAnalysis (what the AI sees) and preprocessing (measured metadata, color, and size-aware sharpness). A third, upscale, appears only when "upscale" or "upscale-hd" was requested in services — see below.
contentAnalysis
AI vision analysis of the preview image.
| Field | Meaning |
|---|---|
subjectMatter | What the image depicts. |
composition | Brief framing/composition assessment. |
obviousIssues | Visible quality problems (noise, cropping, watermarks, etc). Also where a photographed/scanned reproduction of a flat original (painting, print, document) gets called out for visible canvas/paper surface texture (muddy, photocopy-like appearance) or uneven lighting across the original's own surface — both need scene understanding to tell apart from normal photos/intentional lighting, so they're judged here rather than as a deterministic exposureAnalysis flag. Empty if none. |
containsInappropriateContent | true if content isn't suitable for general-audience printing. |
contentWarnings | Specifics when the above is true. |
preprocessing
Deterministic metadata/pixel analysis of the original file.
| Field | Meaning |
|---|---|
nativeWidthPx / nativeHeightPx | Original pixel dimensions. |
exifOrientation | Raw EXIF orientation tag (1–8), unrotated. null if absent. |
cameraMake / cameraModel | From EXIF, if present. |
colorSpace | Best-effort label — "RGB", "CMYK", "Indexed", etc. |
hasEmbeddedColorProfile / embeddedColorProfileName | Whether an ICC profile is embedded, and its name if readable. |
bitDepth | Per-channel bit depth, if determinable. |
colorSpaceFlags | Heuristic warnings — e.g. no_embedded_profile, cmyk_color_space, indexed_color, low_bit_depth. Informational, not pass/fail. |
printSizeCategories
One entry per standard print-size product category. Each reports what the native resolution actually delivers at that size.
| Category | Max side |
|---|---|
extraSmall | ≤ 6" |
small | 7"–10" |
medium | 11"–20" |
large | 21"–36" |
extraLarge | > 36"† |
† Open-ended — reported at a 48" reference size.
| Field | Meaning |
|---|---|
maxSideInches | The category's fixed max side length. |
width / height | Print dimensions in inches at this category. |
dpi | DPI the native resolution delivers at this size. |
grade | Objective DPI classification: poor <75 · fair 75–149 · good 150–299 · excellent 300+ |
recommendedViewingDistanceInches | Suggested minimum viewing distance — balances avoiding visible pixelation against keeping the print's detail appreciable. |
sharpness.assessmentByPrintSize
AI visual sharpness judgment, one per category, keyed the same as printSizeCategories.
| Field | Meaning |
|---|---|
score | 0–100 heuristic reading of measured edge/texture detail. Not a calibrated measurement. |
rawScore | Unprocessed Laplacian-variance value score was derived from. |
grade | AI's contextual sharpness judgment for a print at this size — accounts for the image's own style, so a soft/painterly image isn't graded down for lacking photographic crispness. Capped at printSizeCategories[tier].grade: it can never claim better quality than the physical resolution supports. |
reasoning | Explains what the image's fine detail (whiskers, edges, text) would actually look like printed at that size. |
colorAnalysis.dominantColors
Top colors by pixel frequency, computed directly from pixel data — no AI. Each entry is a hex value and the percentage of sampled pixels it accounts for.
exposureAnalysis
Deterministic exposure/lighting statistics computed directly from pixel data — no AI, same spirit as colorAnalysis.
| Field | Meaning |
|---|---|
meanBrightness | Mean luma (0–255) across the image. |
contrastStdDeviation | Standard deviation of luma (0–255 scale) — lower means flatter/lower-contrast. |
clippedShadowPercentage / clippedHighlightPercentage | Percentage of sampled pixels at or near pure black / pure white. |
lightingUniformityDelta | Brightness spread (0–255 scale) across a 3×3 grid over the image. Technical reference only — not rolled into flags, since a large value can mean either a genuine defect (a flat artwork reproduction lit unevenly across its own surface) or completely normal/intentional scene lighting (e.g. window light in a portrait) — only the vision model's scene understanding can tell those apart. See contentAnalysis.obviousIssues for that judgment when it applies. |
flags | Heuristic warnings: too_dark, overexposed, low_contrast. Not pass/fail. Deliberately excludes lighting evenness — see lightingUniformityDelta above. |
summary | A ready-to-display plain-English sentence built from flags (e.g. "This image looks underexposed and may print too dark.") — none of the numeric fields above are meant to be shown directly to a non-technical end user; this is the human-facing equivalent. Deterministic, not AI-generated. |
upscale
Present only when "upscale" or "upscale-hd" was requested in services — see POST /evaluations. Two tiers of 4x upscaling: a failed upscale never fails the overall evaluation, the contentAnalysis/preprocessing fields above are still returned normally.
services value |
Tier | Model | Notes |
|---|---|---|---|
"upscale" | basic | Real-ESRGAN | Fast, low-cost. Good default for most images. |
"upscale-hd" | hd | Topaz | Sharper, better detail preservation — especially faces/portraits. Costs more per image. |
Both are non-generative — they sharpen/enlarge the pixels already in your source image rather than inventing new detail. If both values are included in the same request, "upscale-hd" takes precedence — they aren't cumulative.
Source size limit. The upscale add-on (both tiers) currently rejects source files over 50MB, separate from and much smaller than the 1.5GB limit for the core evaluation. This is an adjustable operational setting, not a fixed platform limit — if this page and the API's actual behavior ever disagree, trust the error message you get back, since that always reflects the live configured value:
"upscale": { "status": "failed", "errorType": "SourceImageTooLargeException", "errorCause": "Source image (87.3 MB) exceeds the maximum size for upscaling (50.0 MB). The core evaluation still completed normally - resubmit without the upscale add-on, or with a smaller source file, to get an upscaled result." }
| Field | Meaning |
|---|---|
status | "completed" or "failed". |
tier | Which tier actually ran: "basic" or "hd". Only present when status is "completed". |
provider | Which underlying model ran it: "real-esrgan" or "topaz". Only present when status is "completed". |
scaleFactor | Fixed at 4 — not user-selectable. Only present when status is "completed". |
upscaledImageUrl | A presigned, time-limited (1 hour from this response) download URL for the upscaled image. Regenerated fresh on every poll of GET /evaluations/:id, so it never goes stale as long as you keep polling — don't cache it past the response you got it from. Only present when status is "completed". |
errorType / errorCause | Only present when status is "failed" — the exception type and message from whichever step failed. |
// abbreviated — full example in the written reference { "contentAnalysis": { "subjectMatter": "A tabby cat resting on a light-colored couch.", "composition": "Centered subject, shallow depth of field.", "obviousIssues": [], "containsInappropriateContent": false, "contentWarnings": [] }, "preprocessing": { "nativeWidthPx": 2400, "nativeHeightPx": 1602, "colorSpace": "RGB", "hasEmbeddedColorProfile": true, "embeddedColorProfileName": "sRGB IEC61966-2.1", "printSizeCategories": { "extraSmall": { "width": 6.0, "height": 4.0, "dpi": 400.0, "grade": "excellent", "recommendedViewingDistanceInches": 8.6 }, "large": { "width": 36.0, "height": 24.0, "dpi": 66.67, "grade": "poor", "recommendedViewingDistanceInches": 51.57 } }, "sharpness": { "assessmentByPrintSize": { "extraSmall": { "score": 36, "grade": "excellent", "reasoning": "…" }, "large": { "score": 11, "grade": "poor", "reasoning": "…" } } }, "colorAnalysis": { "dominantColors": [{ "hex": "#cfcfce", "percentage": 31.0 }] }, "exposureAnalysis": { "meanBrightness": 132.4, "contrastStdDeviation": 54.2, "lightingUniformityDelta": 12.6, "flags": [], "summary": "Exposure and contrast look good for printing." } }, // only present when "upscale" or "upscale-hd" was requested in services "upscale": { "status": "completed", "tier": "basic", "provider": "real-esrgan", "scaleFactor": 4, "upscaledImageUrl": "https://fwimageeval-assets.s3.amazonaws.com/07fc.../upscaled.jpg?X-Amz-Signature=..." } }
Errors
Request-level errors (4xx/5xx — distinct from a completed/failed evaluation result) share one shape:
{ "error": "invalid_request", "message": "originalUrl is not a well-formed absolute URL." }