Print Evaluation Service

Image Evaluation API

Submit a URL to an image. Get back print-readiness analysis — content, metadata, color, and size-aware sharpness grading for five standard print sizes.

https://api.vonango.com REST · JSON Async (poll for result)

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.

or paste your own image URL

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

Python
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)
Node.js
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

GET /health

Liveness check. Returns 200 { "status": "ok" }. Still requires x-api-key.

POST /evaluations

Starts an evaluation. Processing is asynchronous — this returns immediately with an ID to poll.

Request body
{
  "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 200 directly — 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-Type header

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.

Response — 202 Accepted
{
  "evaluationId": "07fc0511-e795-4cad-919f-e661c316a19c",
  "status": "processing"
}

Errors: 400 invalid_request (missing/malformed field or URL), 500 internal_error.

GET /evaluations/{evaluationId}

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
subjectMatterWhat the image depicts.
compositionBrief framing/composition assessment.
obviousIssuesVisible 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.
containsInappropriateContenttrue if content isn't suitable for general-audience printing.
contentWarningsSpecifics when the above is true.

preprocessing

Deterministic metadata/pixel analysis of the original file.

Field Meaning
nativeWidthPx / nativeHeightPxOriginal pixel dimensions.
exifOrientationRaw EXIF orientation tag (1–8), unrotated. null if absent.
cameraMake / cameraModelFrom EXIF, if present.
colorSpaceBest-effort label — "RGB", "CMYK", "Indexed", etc.
hasEmbeddedColorProfile / embeddedColorProfileNameWhether an ICC profile is embedded, and its name if readable.
bitDepthPer-channel bit depth, if determinable.
colorSpaceFlagsHeuristic 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"
small7"–10"
medium11"–20"
large21"–36"
extraLarge> 36"

† Open-ended — reported at a 48" reference size.

Field Meaning
maxSideInchesThe category's fixed max side length.
width / heightPrint dimensions in inches at this category.
dpiDPI the native resolution delivers at this size.
gradeObjective DPI classification: poor <75 · fair 75–149 · good 150–299 · excellent 300+
recommendedViewingDistanceInchesSuggested 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
score0–100 heuristic reading of measured edge/texture detail. Not a calibrated measurement.
rawScoreUnprocessed Laplacian-variance value score was derived from.
gradeAI'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.
reasoningExplains 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
meanBrightnessMean luma (0–255) across the image.
contrastStdDeviationStandard deviation of luma (0–255 scale) — lower means flatter/lower-contrast.
clippedShadowPercentage / clippedHighlightPercentagePercentage of sampled pixels at or near pure black / pure white.
lightingUniformityDeltaBrightness 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.
flagsHeuristic warnings: too_dark, overexposed, low_contrast. Not pass/fail. Deliberately excludes lighting evenness — see lightingUniformityDelta above.
summaryA 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"basicReal-ESRGANFast, low-cost. Good default for most images.
"upscale-hd"hdTopazSharper, 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".
tierWhich tier actually ran: "basic" or "hd". Only present when status is "completed".
providerWhich underlying model ran it: "real-esrgan" or "topaz". Only present when status is "completed".
scaleFactorFixed at 4 — not user-selectable. Only present when status is "completed".
upscaledImageUrlA 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 / errorCauseOnly present when status is "failed" — the exception type and message from whichever step failed.
Example — status: completed
// 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." }