跳至主要內容

此頁面尚未提供中文版本,你目前看到的是英文版。

Developers

PhotoRescue API

Repair noisy phone photos and enlarge small images from your own code. A JSON REST API with asynchronous jobs, signed webhooks and the same models as the site.

Base URL: https://www.photo-rescue.com/api/v1

Quickstart

The API turns an image into a repaired or larger one. You create a job, wait for it to finish (by polling or by webhook) and download the result. This takes three steps.

  1. Get an API key

    You need an active business plan (API Starter, Growth or Scale). Create a key in the dashboard under API keys. The key is shown once; store it in a secret manager or an environment variable, never in client-side code.

  2. Create your first job

    Send the image as a public HTTPS link (input.url) or as a file you uploaded first (input.upload_id, see uploads), and choose a model with task. The API answers 202 with the job. Credits are charged now and refunded if the job fails.

    curl
    curl -X POST https://www.photo-rescue.com/api/v1/jobs \
      -H "Authorization: Bearer $PHOTORESCUE_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"task":"upscale","input":{"url":"https://example.com/photos/shoe.jpg"}}'

  3. Get the result

    Poll GET /v1/jobs/{id} until status is succeeded, or receive a webhook. output.url is a signed link that expires in one hour. Results are deleted after 24 hours.

A complete example

This script creates a job, polls it every two seconds and saves the result. Set PHOTORESCUE_API_KEY first.

export PHOTORESCUE_API_KEY="pr_live_your_key_here"

# 1. Create a job
curl -X POST https://www.photo-rescue.com/api/v1/jobs \
  -H "Authorization: Bearer $PHOTORESCUE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"task":"upscale","input":{"url":"https://example.com/photos/shoe.jpg"}}'

# 2. Check it (use the "id" from the response)
curl https://www.photo-rescue.com/api/v1/jobs/job_8f3k2m1x \
  -H "Authorization: Bearer $PHOTORESCUE_API_KEY"

# 3. Download output.url from the finished job
curl -o shoe-4x.jpg "https://files.photo-rescue.com/results/job_8f3k2m1x.jpg?signature=..."

Base URL

Every path in this documentation is relative to https://www.photo-rescue.com/api/v1. Requests and responses are JSON, except the file upload in step 2 of uploads.

Authentication

Send your key in the Authorization header as a bearer token on every request. Production keys start with pr_live_; development keys start with pr_test_.

curl https://www.photo-rescue.com/api/v1/usage \
  -H "Authorization: Bearer $PHOTORESCUE_API_KEY"

Looking after keys

  • A key is shown once when you create it. We store only a keyed hash and a short prefix, so we cannot show it again; create a new key if you lose it.
  • Give each key a name (for example the service that uses it), so you can tell them apart and see when each was last used.
  • Revoke a key in the dashboard at any time. A revoked key stops working immediately.
  • Every request is scoped to the account that owns the key. Jobs, uploads and usage of other accounts are never visible.
  • Keep keys on your server. Anyone who has a key can spend your credits.
Authentication errors
StatusCodeMeaning
401invalid_api_keyThe key is missing, malformed or unknown.
403plan_requiredThe account has no active business plan. The site's Pro and Pro+ plans do not include the API.
403key_revokedThe key was revoked in the dashboard.

Models and credits

The task field of a job picks the model. These are the same models as the site and they use the same credit rules: one image costs 1 credit, except Upscale 8× which costs 2. GET /v1/models returns this list live, including whether each model is currently enabled for your account.

Models available through the API
Task idNameScaleCredits per imageMax inputStatus
enhanceEnhance1×1Plan limitCurrent
upscaleUpscale 4×4×15 MPCurrent
upscale-8xUpscale 8×8×21.25 MPCurrent
  • Input limits are checked before anything is charged. An image above a model's cap is refused with 413 image_too_large_for_task.
  • Supported input formats are JPEG, PNG and WebP. output_format can be auto, png, jpeg or webp.

Uploading files

If your images are not on a public HTTPS address, upload them first. Uploading is two requests: ask for a presigned URL with the file size, then send the file to it with a plain PUT with the matching Content-Length. The file goes straight to storage and does not pass through the API servers.

Create an upload URL

POST/v1/uploads

Returns a presigned URL for a direct PUT and an upload_id to use in POST /v1/jobs. The URL expires at expires_at; request a new one if it has passed. The file size limit is your plan's input cap.

Request

# Step 1: ask for an upload URL (must include file size)
curl -X POST https://www.photo-rescue.com/api/v1/uploads \
  -H "Authorization: Bearer $PHOTORESCUE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"size": 482113}'

# Step 2: send the file to the returned url
curl -X PUT "https://uploads.photo-rescue.com/...signed-url..." \
  -H "Content-Type: image/jpeg" \
  -H "Content-Length: 482113" \
  --data-binary @shoe.jpg

Response

JSON
{
  "upload_id": "upl_5d9q0w2e",
  "url": "https://uploads.photo-rescue.com/...signed-url...",
  "expires_at": "2026-10-10T14:18:21Z"
}

Status 200. The PUT must send the Content-Length header with exactly the same byte count as specified in the request. Use the same Content-Type as the file has.

Jobs

A job is one image processed by one model. Jobs run asynchronously: creating one returns immediately, and you read the outcome by polling or webhook.

The job object
FieldTypeDescription
idstringJob identifier, starts with job_.
objectstringAlways job.
statusstringqueued, processing, succeeded, failed or canceled.
taskstringThe model id, as in the models table.
creditsintegerCredits charged for this job.
created_at, completed_atISO 8601 / nullWhen the job was accepted and when it finished (null until then).
inputobjectwidth and height of the image you sent.
outputobject / nullOnce succeeded: url (signed, expires in one hour), width, height, format and expires_at of the link.
errorobject / nullOnce failed: code and message.
metadataobjectThe key-value pairs you sent when creating the job.
progressobject / nullWhile queued or processing: stage, fraction (0 to 1) and eta_ms (null until measured). starting means the job waits for a GPU; queued means the GPU is up and the image waits for its turn; then download, restore, refine, encode, upload and saving. null once finished.
batch_idstring / nullThe batch the job belongs to, when it was created with POST /v1/batches.
batch_indexinteger / nullThe position (from 0) of the item in the POST /v1/batches request. Batch jobs are listed in this order.

Create a job

POST/v1/jobs

Validates the image, charges the credits and queues the job. The image is checked first (type, decoding, shape and the model's size cap), so a refused image is never charged.

URL inputs are fetched by our servers: the address must be HTTPS and public; private network addresses are refused, and the size and file type are checked.

Send an Idempotency-Key header to make retries safe (see idempotency).

Request body
FieldRequiredDescription
taskyesenhance, upscale or upscale-8x.
inputyes{ "upload_id": "..." } from uploads, or { "url": "https://..." }.
output_formatnoauto (default), png, jpeg or webp.
webhook_urlnoHTTPS address that receives job.succeeded and job.failed for this job. See webhooks.
metadatanoUp to 20 keys with string values, returned with the job and in webhooks.

Request

curl -X POST https://www.photo-rescue.com/api/v1/jobs \
  -H "Authorization: Bearer $PHOTORESCUE_API_KEY" \
  -H "Idempotency-Key: order-1042-photo-1" \
  -H "Content-Type: application/json" \
  -d '{
    "task": "upscale",
    "input": { "url": "https://example.com/photos/shoe.jpg" },
    "output_format": "auto",
    "webhook_url": "https://example.com/webhooks/photorescue",
    "metadata": { "sku": "SHOE-1042" }
  }'

Response

JSON
{
  "id": "job_8f3k2m1x",
  "object": "job",
  "status": "queued",
  "task": "upscale",
  "credits": 1,
  "created_at": "2026-10-10T14:03:21Z",
  "completed_at": null,
  "input": { "width": 1024, "height": 683 },
  "output": null,
  "error": null,
  "metadata": { "sku": "SHOE-1042" },
  "progress": { "stage": "starting", "fraction": 0, "eta_ms": null },
  "batch_id": null,
  "batch_index": null
}

Status 202. Errors include 402 insufficient_credits (with details.needed and details.remaining), 413 image_too_large_for_task, 415 unsupported_format, 422 image_damaged and 422 image_shape. See errors.

Retrieve a job

GET/v1/jobs/{id}

Returns the job and, once it has succeeded, its result link. Calling it again while the file still exists returns a fresh signed link, so you can refresh an expired one within the 24 hours.

Request

curl https://www.photo-rescue.com/api/v1/jobs/job_8f3k2m1x \
  -H "Authorization: Bearer $PHOTORESCUE_API_KEY"

Response

JSON
{
  "id": "job_8f3k2m1x",
  "object": "job",
  "status": "succeeded",
  "task": "upscale",
  "credits": 1,
  "created_at": "2026-10-10T14:03:21Z",
  "completed_at": "2026-10-10T14:03:40Z",
  "input": { "width": 1024, "height": 683 },
  "output": {
    "url": "https://files.photo-rescue.com/results/job_8f3k2m1x.jpg?signature=...",
    "width": 4096,
    "height": 2732,
    "format": "jpeg",
    "expires_at": "2026-10-10T15:03:40Z"
  },
  "error": null,
  "metadata": { "sku": "SHOE-1042" },
  "progress": null,
  "batch_id": null,
  "batch_index": null
}

Status 200. Poll every couple of seconds at most; a webhook avoids polling altogether.

List jobs

GET/v1/jobs

Returns your jobs, newest first, with cursor pagination.

Query parameters
ParameterDescription
limitHow many jobs to return. Default 20, at most 100.
cursorThe cursor from the previous page. Omit it for the first page.
statusOnly jobs with this status: queued, processing, succeeded, failed or canceled.

Request

curl "https://www.photo-rescue.com/api/v1/jobs?status=failed&limit=20" \
  -H "Authorization: Bearer $PHOTORESCUE_API_KEY"

Response

JSON
{
  "object": "list",
  "data": [
    { "id": "job_2c7n9b4v", "object": "job", "status": "failed", "task": "enhance", "credits": 1, "...": "..." }
  ],
  "has_more": true,
  "next_cursor": "cur_Zm9v"
}

Status 200. Pass next_cursor as cursor to read the next page.

Cancel a job

DELETE/v1/jobs/{id}

Cancels a queued job. If it has not started, the credits are refunded. A job that is already processing or finished cannot be canceled.

Request

curl -X DELETE https://www.photo-rescue.com/api/v1/jobs/job_8f3k2m1x \
  -H "Authorization: Bearer $PHOTORESCUE_API_KEY"

Response

JSON
{
  "id": "job_8f3k2m1x",
  "object": "job",
  "status": "canceled",
  "task": "upscale",
  "credits": 1,
  "created_at": "2026-10-10T14:03:21Z",
  "completed_at": "2026-10-10T14:03:25Z",
  "input": { "width": 1024, "height": 683 },
  "output": null,
  "error": null,
  "metadata": { "sku": "SHOE-1042" },
  "progress": { "stage": "starting", "fraction": 0, "eta_ms": null },
  "batch_id": null,
  "batch_index": null
}

Status 200 with the job, now canceled.

Batches

A batch is many images of one task sent in one request. Each item becomes a normal job (same credits, same refunds, same job object) with a shared batch_id, and the jobs are processed together, which is faster and cheaper for us to run than the same images sent one by one.

Create a batch

POST/v1/batches

Accepts 1 to 500 items. Every item is validated and charged exactly like POST /v1/jobs. Items that cannot be used (for example a damaged file) are listed in rejected with their index and error, and cost nothing; the others are accepted. When no item can be accepted the answer is 422 batch_rejected.

The request is refused with 402 insufficient_credits when your credits do not cover every item. Send an Idempotency-Key header to make retries safe: a retry with the same body returns the first batch, and finishes it if the first request was interrupted. The same key with a different body is refused with 409 idempotency_conflict.

Request body
FieldRequiredDescription
taskyesOne task for every item, as in POST /v1/jobs.
itemsyes1 to 500 objects { "input": {...}, "metadata": {...} }; input as in POST /v1/jobs, metadata optional per item.
output_formatnoauto (default), png, jpeg or webp, for every item.
webhook_urlnoHTTPS address that receives one batch.completed event when every job has finished.
metadatanoKey-value pairs for the batch itself.

Request

curl -X POST https://www.photo-rescue.com/api/v1/batches \
  -H "Authorization: Bearer $PHOTORESCUE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: catalog-2026-10-10" \
  -d '{
    "task": "upscale",
    "items": [
      { "input": { "upload_id": "upl_3hT9kLmN2pQ8rS4vW6xY1zA0" }, "metadata": { "sku": "SKU-1" } },
      { "input": { "url": "https://example.com/sku-2.jpg" }, "metadata": { "sku": "SKU-2" } }
    ],
    "webhook_url": "https://example.com/webhooks/photorescue"
  }'

Response

JSON
{
  "id": "batch_4gh7jk2l",
  "object": "batch",
  "status": "processing",
  "task": "upscale",
  "created_at": "2026-10-10T14:03:21Z",
  "completed_at": null,
  "counts": { "total": 2, "queued": 2, "processing": 0, "succeeded": 0, "failed": 0, "canceled": 0 },
  "progress": 0,
  "metadata": { "shoot": "spring-catalog" },
  "jobs": [
    { "id": "job_b1a2t3c4", "object": "job", "status": "queued", "batch_id": "batch_4gh7jk2l", "batch_index": 0, "...": "..." },
    { "id": "job_b5d6e7f8", "object": "job", "status": "queued", "batch_id": "batch_4gh7jk2l", "batch_index": 1, "...": "..." }
  ],
  "rejected": []
}

Status 202. Credits are charged for the accepted items only.

Read a batch

GET/v1/batches/{id}

Returns the counts per status, the overall progress (0 to 1, finished jobs count as 1) and every job of the batch, each with a fresh output URL while its result exists.

Request

curl https://www.photo-rescue.com/api/v1/batches/batch_4gh7jk2l \
  -H "Authorization: Bearer $PHOTORESCUE_API_KEY"

Response

JSON
{
  "id": "batch_4gh7jk2l",
  "object": "batch",
  "status": "processing",
  "task": "upscale",
  "created_at": "2026-10-10T14:03:21Z",
  "completed_at": null,
  "counts": { "total": 2, "queued": 2, "processing": 0, "succeeded": 0, "failed": 0, "canceled": 0 },
  "progress": 0,
  "metadata": { "shoot": "spring-catalog" },
  "jobs": [
    { "id": "job_b1a2t3c4", "object": "job", "status": "queued", "batch_id": "batch_4gh7jk2l", "batch_index": 0, "...": "..." },
    { "id": "job_b5d6e7f8", "object": "job", "status": "queued", "batch_id": "batch_4gh7jk2l", "batch_index": 1, "...": "..." }
  ]
}

Status 200. status becomes completed when every job has succeeded, failed or been canceled.

Usage

Read your credits

GET/v1/usage

Returns the current billing period and how many credits you have used and have left. Credits reset every period and unused credits do not roll over.

Request

curl https://www.photo-rescue.com/api/v1/usage \
  -H "Authorization: Bearer $PHOTORESCUE_API_KEY"

Response

JSON
{
  "object": "usage",
  "plan": "api_growth",
  "api_access": true,
  "period_start": "2026-10-01T00:00:00Z",
  "period_end": "2026-11-01T00:00:00Z",
  "credits_included": 8000,
  "credits_used": 1250,
  "credits_remaining": 6750,
  "overage_credits": 0
}

Status 200. Business plans have no overage: when credits_remaining reaches 0, new jobs are refused with 402 insufficient_credits.

Webhooks

Instead of polling, pass a webhook_url when you create a job. When the job finishes, we send a POST with a JSON event to that address.

Event body
{
  "id": "evt_4b8c1d2e",
  "type": "job.succeeded",
  "data": {
    "id": "job_8f3k2m1x",
    "object": "job",
    "status": "succeeded",
    "task": "upscale",
    "credits": 1,
    "created_at": "2026-10-10T14:03:21Z",
    "completed_at": "2026-10-10T14:03:40Z",
    "input": { "width": 1024, "height": 683 },
    "output": { "url": "https://files.photo-rescue.com/results/job_8f3k2m1x.jpg?signature=...", "width": 4096, "height": 2732, "format": "jpeg", "expires_at": "2026-10-10T15:03:40Z" },
    "error": null,
    "metadata": { "sku": "SHOE-1042" }
  }
}

Events
TypeSent when
job.succeededThe result is ready. data.output.url is a signed link.
job.failedThe job failed. Its credits are refunded automatically. data.error has the reason.
batch.completedEvery job of a batch has finished. data is the batch object with its jobs. Sent to the batch's webhook_url.
webhook.testSent from the dashboard's test button. data has a message and a test_id.

Verify the signature

Every request carries PhotoRescue-Event (the event type), PhotoRescue-Delivery (the event id, the same as id in the body) and a PhotoRescue-Signature header like t=1760105020,v1=5257a869.... t is a Unix timestamp and v1 is the hex HMAC-SHA256, keyed with your webhook secret, of the string t.body: the timestamp, a dot, then the raw request body. Recompute it and compare in constant time. Reject requests whose timestamp is more than a few minutes old, so a captured request cannot be replayed.

Find your webhook secret in the dashboard under Webhooks, where you can also rotate it and send a test event.

import hashlib
import hmac
import time


def verify_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    """Check the PhotoRescue-Signature header of a webhook request."""
    try:
        parts = dict(p.strip().split("=", 1) for p in header.split(","))
        timestamp, signature = parts["t"], parts["v1"]
        if abs(time.time() - int(timestamp)) > tolerance:
            return False  # too old: possible replay
    except (KeyError, ValueError):
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Use the raw body

Compute the signature over the bytes exactly as received. Parsing the JSON and serialising it again changes the bytes and the check fails.

A minimal receiver

import os
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["PHOTORESCUE_WEBHOOK_SECRET"]


@app.post("/webhooks/photorescue")
def photorescue_webhook():
    raw = request.get_data()  # raw bytes, before any JSON parsing
    if not verify_signature(raw, request.headers.get("PhotoRescue-Signature", ""), SECRET):
        return "invalid signature", 400
    event = request.get_json()
    job = event["data"]
    if event["type"] == "job.succeeded":
        print("done", job["id"], job["output"]["url"])
    elif event["type"] == "job.failed":
        print("failed", job["id"], job["error"])
    return "", 204

Retries

  • A delivery that does not succeed is retried up to 5 times with exponential backoff, over at most about an hour.
  • Answer with a 2xx status as soon as you have stored the event; do slow work afterwards.
  • Deliveries can repeat. Treat the event id (or the job id) as the key and ignore events you have already handled.
  • The dashboard lists recent deliveries with their status. If an endpoint was down, you can always read the job with GET /v1/jobs/{id}.

Errors

Errors use an HTTP status that fits and one JSON shape, so you can branch on error.code. The message is for people, details carries structured data when there is any.

Error body
{
  "error": {
    "code": "insufficient_credits",
    "message": "This job needs 2 credits and your plan has 1 left.",
    "details": { "needed": 2, "remaining": 1 }
  }
}

Error codes
StatusCodeMeaning and what to do
400invalid_requestThe body or a parameter is not valid. details says which.
400task_unavailableThe model is not available right now, or it is not one of the current models. details.allowed lists the models you can use.
401invalid_api_keyThe key is missing, malformed or unknown. Check the Authorization header.
402insufficient_creditsNot enough credits. details has needed and remaining. Upgrade the plan or wait for the next period.
403plan_requiredThe account has no active business plan.
403key_revokedThe key was revoked. Create a new one.
404not_found, upload_not_foundNo such job, or no uploaded file for that upload_id. Upload the file before creating the job.
405method_not_allowedThe endpoint does not support this HTTP method. The Allow header lists the supported ones.
409idempotency_conflictThis Idempotency-Key was already used with a different body.
409idempotency_in_progressA request with this key is still being processed. Retry shortly.
409job_not_cancelableOnly queued jobs can be canceled.
413file_too_large, image_too_large_for_taskThe file is above your plan's size limit, or the image has more megapixels than the model accepts. Pick another model or resize it.
415unsupported_formatNot a JPEG, PNG or WebP file.
422image_damagedThe file could not be decoded.
422batch_rejectedNone of the items of a batch could be accepted. details.rejected lists the index and error of every item. Nothing was charged.
422image_shapeThe image is under 16 px on a side or more elongated than 12:1.
422input_url_rejected, input_url_unreachableThe input.url is not a public HTTPS address, or it could not be downloaded.
429rate_limitedToo many requests. Wait Retry-After seconds. See rate limits.
429concurrency_limitToo many jobs in progress for the account. Wait for one to finish.
500, 503internal_error, quota_unavailable, service_unavailableA problem on our side (for example the rate limiter or the credit service is briefly unavailable). Retry with backoff; honour Retry-After when present.
  • Credits are never charged for a request that returns an error before the job is accepted.
  • A job that fails after it was accepted is refunded automatically and reported as job.failed.
  • New error codes may be added. Treat an unknown code as its HTTP status class: retry 5xx with backoff, do not retry 4xx unchanged.

Rate limits

Limits apply per API key, and there is also a cap on how many jobs your account can have running at the same time. They depend on your plan.

Rate limits by plan
PlanRequests per minute (per key)Concurrent jobs (per account)Included credits
API Starter6042,000
API Growth300168,000
API Scale1,0004830,000

When you go over, the API answers 429 rate_limited. Every response carries RateLimit-Limit and RateLimit-Remaining, and a 429 also has Retry-After: the number of seconds to wait before trying again.

import time
import requests


def request_with_retry(method, url, retries=5, **kwargs):
    for attempt in range(retries):
        r = requests.request(method, url, **kwargs)
        if r.status_code != 429:
            return r
        time.sleep(int(r.headers.get("Retry-After", 2 ** attempt)))
    return r

Jobs beyond your concurrency cap are not lost: spread them out, or use a webhook and send the next job when one finishes.

Idempotency

Networks fail. If a POST /v1/jobs times out, you cannot tell whether the job was created, and a blind retry could charge twice. Send an Idempotency-Key header with a value that is unique per logical request (an order and photo id works well).

  • The same key with the same body within 24 hours returns the job that was already created, with no second charge.
  • Use a new key for every new image. Reusing a key with a different body is a mistake in your code.
  • Keys are scoped to your account, so they cannot clash with other customers' keys.
curl
curl -X POST https://www.photo-rescue.com/api/v1/jobs \
  -H "Authorization: Bearer $PHOTORESCUE_API_KEY" \
  -H "Idempotency-Key: order-1042-photo-1" \
  -H "Content-Type: application/json" \
  -d '{"task":"enhance","input":{"url":"https://example.com/photos/room.jpg"}}'

Data retention

Your images are not kept. Uploaded files and results are deleted automatically after 24 hours, as the privacy policy says.

  • Download results soon after the job succeeds. After 24 hours the file is gone and cannot be recovered.
  • output.url is a signed link that expires in one hour. While the file still exists, GET /v1/jobs/{id} returns a fresh link.
  • We do not use your images to train, fine-tune or evaluate AI models. See the Business & API Terms and the Data Processing Agreement.
  • Job records (id, model, time, status and credits, with no image data) are kept longer than the images. The DPA gives the period.

Changelog

API changes
DateVersionChange
2026-10-10v1First public version: models, uploads, jobs, usage, webhooks, rate limits and idempotency keys.
2026-10-10v1Batches (POST /v1/batches, GET /v1/batches/{id}, batch.completed), and live progress, batch_id and batch_index on the job object.

Changes that break existing integrations will ship under a new version path. New fields and new error codes can appear in v1, so ignore fields you do not know.

OpenAPI 3.1 specification