Upscale Pro
FOTOhub Upscale Pro 4K is a generative upscaler for images and short video spans. It rebuilds detail instead of only interpolating pixels, up to 16.7 MP (4096 x 4096) for an image and up to 1080p for a video. It runs as a job: you start it, you are charged once, and you poll the job (an image usually finishes inside the request). A job that fails or is cancelled is refunded automatically.
Capabilities
- Images: 2x, 3x or 4x, output up to 16.7 MP; the scale is lowered automatically when the requested one would not fit
- Video: 2x, 3x or 4x of one span of up to 30 s and 240 frames, output up to 1920 x 1080
- Price shown first: the free
quoteroute tells you the applied scale, output size, ETA, billed units and whether your account can run it - Failed means not charged: every failure, timeout or cancel is refunded once
- EU processing: Upscale Pro jobs are processed in the EU (
processing_region: "eu")
Plan and availability
Upscale Pro is part of the Professional plan and above. On a lower plan every start answers 403 plan_required (with the plan and the upgrade path) and nothing is charged. While the feature is not released to your account, starts answer 403 feature_disabled and quote answers {"available": false, "reason": "feature_disabled", "flag_on": false}.
Endpoints
| Method | Path | Charged | Description |
|---|---|---|---|
| POST | /v1/upscale/pro/image | yes | Upscale an image (200 with the result when it finishes within ~55 s, else 202) |
| POST | /v1/upscale/pro/video | yes | Upscale a video span (always 202) |
| POST | /v1/upscale/pro/quote | no | Plan check, applied scale, output size, ETA, billed units, credits (video) |
| GET | /v1/upscale/pro/jobs | no | Your recent jobs, newest first |
| GET | /v1/upscale/pro/jobs/{job_id} | no | Status of one job; result once it succeeded |
| POST | /v1/upscale/pro/jobs/{job_id}/cancel | no | Cancel a queued or running job; refunded at once |
POST /v1/video/upscale with "quality": "pro" starts the same video job (see Video Editing).
Base URL: https://apis.fotohub.appAuthentication: Authorization: Bearer fh_live_your_api_key (see Authentication). Every route above also accepts a dashboard session.
Pricing
Prices are not hard-coded in this page. They come from the API:
- An image is one unit, charged once when the job is accepted.
- A video is charged per second of the span, rounded up to whole 10-second blocks (at least one block; a span up to 0.05 s over a block boundary is not billed as a new block). There are two video rates: output up to 1280 x 720 pixels, and output above that (up to 1080p).
POST /v1/upscale/pro/quotereturns theprice_keyand the billedunitsfor the size you send. The USD wallet rate of that key is listed byGET /v1/pricing; the charge of a started job is in its response (cost_usd,billing).
Upscale an image
POST /v1/upscale/pro/image| Field | Type | Default | Description |
|---|---|---|---|
image_url | string | required | https:// URL or data:image/...;base64,... |
scale | integer | 4 | 2, 3 or 4. Lowered automatically to the largest whole scale whose output fits 16.7 MP; the job reports requested_scale, scale and clamped |
wait | boolean | true | Wait up to ~55 s for the result and answer 200; when it is not done by then, answer 202 and poll |
client_ref | object | none | Your own context, echoed back in the job. At most 2 KB as JSON |
Input limits: at most 25 MB and 40 MP; each side at least 32 px. The output is limited to 16.7 MP, so a 2x upscale needs a source of at most 4.1 MP (for example 2048 x 2048). A larger source answers 422 pro_limit with code: "too_large".
curl https://apis.fotohub.app/v1/upscale/pro/image \
-H "Authorization: Bearer $FOTOHUB_API_KEY" -H "Content-Type: application/json" \
-d '{"image_url": "https://example.com/product.jpg", "scale": 4}'import os, time, requests
API = "https://apis.fotohub.app"
H = {"Authorization": f"Bearer {os.environ['FOTOHUB_API_KEY']}"}
r = requests.post(f"{API}/v1/upscale/pro/image", headers=H,
json={"image_url": "https://example.com/product.jpg", "scale": 4}, timeout=90)
job = r.json()
if r.status_code == 202: # not finished within ~55 s: poll
while job["status"] in ("queued", "running"):
time.sleep(5)
job = requests.get(f"{API}{job['poll_url']}", headers=H, timeout=30).json()
if job["status"] == "succeeded":
print(job["result"]["url"], job["result"]["width"], job["result"]["height"])
else:
print(job.get("error_code"), job.get("error"), "refunded:", job.get("refunded"))const API = "https://apis.fotohub.app";
const H = { Authorization: `Bearer ${process.env.FOTOHUB_API_KEY}`, "Content-Type": "application/json" };
const res = await fetch(`${API}/v1/upscale/pro/image`, {
method: "POST", headers: H,
body: JSON.stringify({ image_url: "https://example.com/product.jpg", scale: 4 }),
});
let job: any = await res.json();
while (job.status === "queued" || job.status === "running") {
await new Promise((r) => setTimeout(r, 5000));
job = await (await fetch(`${API}${job.poll_url}`, { headers: H })).json();
}
console.log(job.status, job.result?.url);Status codes: 200 finished within the wait (the body is the job with result), 202 accepted and still running, 424 the job failed or was cancelled within the wait (the body is the job with error_code, error and refunded). All three carry the charge fields.
Upscale a video
POST /v1/upscale/pro/video| Field | Type | Default | Description |
|---|---|---|---|
video_url | string | required | https:// URL of the source video |
scale | integer | 2 | 2, 3 or 4. Lowered automatically to the largest whole scale whose output fits 1920 x 1080 |
start_seconds | number | 0 | Where the span starts (>= 0) |
max_seconds | number | to the end | Span length (> 0) |
quote_credits | number | none | Optional. The credits of POST /v1/upscale/pro/quote you showed and confirmed. See Price check |
client_ref | object | none | Echoed back in the job. At most 2 KB as JSON |
Input limits:
- The source file is at most 1 GB; each side at least 32 px.
- One job is one span of at most 30 s and at most 240 frames (8 s of a 30 fps clip). A longer span is refused with the exact limit for that clip's frame rate (
422 pro_limit,code: "too_long",limits.max_frames,limits.max_seconds), not split into several jobs: separate jobs would leave a visible seam. Usestart_seconds/max_secondsto choose the span. - The output is at most 1920 x 1080 (long side 1920, short side 1080), so a 2x upscale needs a source of at most 960 x 540.
start_secondspast the end of the video answers422 bad_input. The prepared span must stay under 95 MB, otherwise413 too_large("trim the clip").
The video route always answers 202 with the job (frames, seconds, billed_seconds included) and the charge fields.
curl https://apis.fotohub.app/v1/upscale/pro/video \
-H "Authorization: Bearer $FOTOHUB_API_KEY" -H "Content-Type: application/json" \
-d '{"video_url": "https://example.com/clip-540p.mp4", "scale": 2, "start_seconds": 4, "max_seconds": 6}'import os, time, requests
API = "https://apis.fotohub.app"
H = {"Authorization": f"Bearer {os.environ['FOTOHUB_API_KEY']}"}
job = requests.post(f"{API}/v1/upscale/pro/video", headers=H, json={
"video_url": "https://example.com/clip-540p.mp4", "scale": 2,
"start_seconds": 4, "max_seconds": 6,
}, timeout=300).json()
print("billed seconds:", job["billed_seconds"], "charged USD:", job["cost_usd"])
while job["status"] in ("queued", "running"):
time.sleep(max(5, min(job.get("eta_s") or 15, 30)))
job = requests.get(f"{API}{job['poll_url']}", headers=H, timeout=30).json()
print(job["status"], job.get("result", {}).get("url"))const API = "https://apis.fotohub.app";
const H = { Authorization: `Bearer ${process.env.FOTOHUB_API_KEY}`, "Content-Type": "application/json" };
let job: any = await (await fetch(`${API}/v1/upscale/pro/video`, {
method: "POST", headers: H,
body: JSON.stringify({ video_url: "https://example.com/clip-540p.mp4", scale: 2, start_seconds: 4, max_seconds: 6 }),
})).json();
while (job.status === "queued" || job.status === "running") {
await new Promise((r) => setTimeout(r, 15000));
job = await (await fetch(`${API}${job.poll_url}`, { headers: H })).json();
}
console.log(job.status, job.result?.url);The job
Every start, GET /jobs/{job_id} and a successful cancel answer the same job object:
{
"job_id": "3f0c6a8e-6f4e-4a8b-9a51-2f3d1c0e9b7a",
"kind": "video", // "image" | "video"
"status": "running", // queued | running | succeeded | failed | cancelled
"quality": "pro",
"scale": 2,
"requested_scale": 2,
"clamped": false, // true when the scale was lowered to fit
"source_size": {"width": 960, "height": 540},
"output_size": {"width": 1920, "height": 1080},
"estimate_s": 952, // the whole job
"eta_s": 700, // left; null once finished
"progress": 0.25, // 1.0 once succeeded
"queue_position": null,
"created_at": "2026-10-09T10:00:00+00:00",
"started_at": "2026-10-09T10:00:05+00:00",
"finished_at": null,
"client_ref": null,
"processing_region": "eu",
"poll_url": "/v1/upscale/pro/jobs/3f0c6a8e-6f4e-4a8b-9a51-2f3d1c0e9b7a",
"frames": 180, // video only
"seconds": 6.0, // video only
"billed_seconds": 10 // video only
// start responses add the charge fields: cost_usd, currency, billing
}result(oncestatusissucceeded):url,storage_path,bucket,mime_type,width,height,size_bytes,scale; a video addsduration_sandframes.- On
failed/cancelled:error_code,error(a sentence for the user) andrefunded(true once the money is back).error_codeis one ofengine_busy,engine_failed,engine_rejected,timeout,lost,bad_result,storage,interrupted,cancelled. - Charge fields (start responses only):
cost_usd,currency: "USD"andbilling(cost_usd,balance_usd,currency,method,model: "prepaid").
When a job succeeds, a image.upscale.pro.completed or video.upscale.pro.completed webhook event is sent with job_id, url, width and height.
List and cancel
GET /v1/upscale/pro/jobs returns {"jobs": [...]}, newest first. Query: active=true (unfinished only), kind=image|video, limit (1-50, default 20).
POST /v1/upscale/pro/jobs/{job_id}/cancel stops a queued or running job and refunds it at once; the answer is the job with status: "cancelled". A job that already finished, or is being delivered, answers 409:
{"detail": {"error": "not_cancellable", "status": "succeeded",
"message": "This upscale has already finished or is being delivered."}}An unknown id, or a job of another account, answers 404 {"detail": "Job not found"}.
Quote
POST /v1/upscale/pro/quoteFree. Send the size you are about to upscale; nothing is fetched and nothing is charged. The example below is a 640 x 360, 24 fps, 10 s span at scale: 2.
| Field | Type | Default | Description |
|---|---|---|---|
kind | "image" | "video" | required | |
width, height | integer | required | Source size in pixels, 1-100000 |
scale | integer | 4 | 2, 3 or 4 |
fps | number | none | Video only (required for a video quote), > 0 and <= 1000 |
seconds | number | none | Video only (required for a video quote): span length, > 0 and <= 86400 |
{
"flag_on": true,
"plan_ok": true,
"required_plan": "professional",
"upgrade_url": "/generate/prices",
"kind": "video",
"price_key": "<key to look up in GET /v1/pricing>",
"processing_region": "eu",
"limits": {"max_vid_frames": 240, "max_vid_long_side": 1920, "max_vid_short_side": 1080,
"max_vid_out_px": 2073600, "max_img_out_px": 16777216},
"scale": 2,
"clamped": false,
"frames": 240,
"output_size": {"width": 1280, "height": 720},
"units": 10,
"billed_seconds": 10,
"estimate_s": 572,
"eligible": true,
"available": true,
"credits": 55.0
}availableis true only when your plan includes Upscale Pro, the size is eligible and the service is up. Otherwisereasonsays why:plan_required,engine_unavailableorpro_limit.eligible: falsecomes withrefusal: {code, message, limits}, the same codes a start would answer (too_small,too_large,too_long,bad_scale,unreadable). A video quote withoutfpsandsecondsisunreadable.limitsare the capacity the job would be planned with right now. The defaults are shown above; they can be higher at times, so read them rather than hard-coding them.cold_start: trueappears when the model has to load first;estimate_salready includes it.credits(video only, whenavailable) is the price of this span in FOTOhub credits, ornullwhen the account pays only from the USD wallet. It is the number to send back asquote_credits.
Price check
The video quote prices the size and length you report; the start measures the real file. Send the quote's credits as quote_credits on POST /v1/upscale/pro/video (or POST /v1/video/upscale with quality: "pro") and the start is refused when the measured span costs more than that figure. A lower measured price is simply charged as measured. Without quote_credits there is no check, and when the account has no credit price the check is skipped.
The refusal is 409, before any charge:
{
"detail": {
"error": "price_changed",
"message": "The price changed since it was shown: this video is longer or larger than quoted. Check the new price. Your wallet was not charged for this request.",
"quoted_credits": 55.0,
"current_credits": 110.0,
"billed_seconds": 20
}
}Quote again, show the new price, and start again with the new quote_credits. See also Price changed (409).
Errors
Every refusal before the charge says so in its message ("Your wallet was not charged for this request.").
| Status | error | When |
|---|---|---|
| 400 | bad_input, unreadable | The URL could not be fetched or is not https (video), the base64 is invalid, the file is not a readable image or video, or the video has no video stream |
| 400 | url_blocked | The URL is not a public https URL (see URL refused) |
| 402 | insufficient_funds | The wallet cannot cover the charge (see 402 Payment Required) |
| 403 | feature_disabled | Upscale Pro is not released to your account yet: {"error": "feature_disabled", "flag": "<feature id>"} |
| 403 | plan_required | Your plan does not include it: required_plan, upgrade_url, feature, message |
| 409 | host_capacity | The span or image needs more capacity than is available right now: code, limits, charged: false. Try again later or choose a shorter span |
| 409 | price_changed | The measured video span costs more than quote_credits (see above) |
| 409 | not_cancellable | Cancel of a finished job |
| 413 | too_large | Image over 25 MB or 40 MP, video over 1 GB, or a prepared span over 95 MB |
| 422 | pro_limit | The input cannot be upscaled as asked: code (too_small, too_large, too_long, bad_scale, unreadable), message, limits |
| 422 | bad_input | start_seconds is past the end of the video |
| 424 | (job) | Image only, with wait: true: the job failed or was cancelled within the wait; refunded |
| 429 | engine_busy | The upscaler is busy: retry_after_s and a Retry-After header |
| 503 | engine_unavailable, plan_unavailable | The upscaler or the plan check is temporarily unavailable |
The 409 host_capacity, 422 pro_limit and 429 engine_busy bodies have this shape:
{
"detail": {
"error": "pro_limit",
"message": "Pro 4K video handles up to 240 frames per job, i.e. 8 s of this 30 fps clip (this span is 12.0 s, 360 frames). Trim the clip or use Standard. Your wallet was not charged for this request.",
"code": "too_long",
"limits": {"max_frames": 240, "max_seconds": 8.0, "fps": 30.0, "frames": 360}
}
}Related APIs
- Video Editing —
POST /v1/video/upscale(Standard andquality: "pro") - Billing & Pricing — wallet top-ups and
GET /v1/pricing - Errors — error format,
price_changed,url_blocked

