# Retouch Homes API > Retouch Homes (retouch.homes) edits real estate listing photos automatically: it straightens walls, levels horizons, crops, and sets exposure, white balance and sharpness. It never invents pixels: every edit moves or re-weights pixels already in the photo, and nothing in the room is added, removed or changed. Each photo gets a confidence score, and photos below 0.4 are flagged for a human look. During the beta, the API fetches photos only from hosts on its allowlist; a photo on any other host fails with `photo host … is not allowed`. To add yours, write to hello@retouch.homes. - Base URL: https://retouch.homes/api/v1 - OpenAPI spec: https://retouch.homes/openapi.json - API catalog (RFC 9727): https://retouch.homes/.well-known/api-catalog - Terms: https://retouch.homes/terms - Contact: hello@retouch.homes ## What it does and doesn't do - Edits how a photo looks: straightening (verticals and horizon), cropping, exposure, white balance and sharpening, plus lens correction where a wide lens bowed straight lines. - Never changes what the property looks like. No new sky, no removed objects, no virtual staging, no generated pixels. - Flags a photo instead of passing it off as done when it isn't sure (`flagged`, `confidence`). - Doesn't take uploads through the API: send URLs our server can fetch. ## Authentication Create a secret key at https://retouch.homes/app/api-keys. Secret keys start `rth_sk_live_`. Send it as a Bearer token from your server, never from a browser: ``` Authorization: Bearer rth_sk_live_… ``` ## Edit photos `POST /api/v1/photos` takes up to 40 photo URLs and returns one photo object per URL, in the order sent. It's synchronous: a full batch can take up to about 200 seconds, so set a client timeout of at least 300 seconds. Body fields: - `photos` (required): an array of 1 to 40 http(s) URLs, each at most 2048 characters. - `listing` (optional): your own listing ID, up to 200 characters. It's stored on each photo and shown in your logs. - `long_edge` (optional): the output's longer side, 256 to 4096 pixels. Left out, the output keeps the edited photo's size. - `format` (optional): `"jpeg"` or `"webp"`, default `"jpeg"`. - `market` (optional): the listing's market, as a slug from the [rules library](https://retouch.homes/rules), such as `"malaysia"`. Window recovery runs only when it's sent and its rules don't ask for a label on AI edits (Singapore, Bay East and Canopy do), since a model finds the windows. Left out, window recovery is off. ### curl ```bash curl https://retouch.homes/api/v1/photos \ -H "Authorization: Bearer $RETOUCH_API_KEY" \ -H "Content-Type: application/json" \ --max-time 300 \ -d '{"photos": ["https://photos.example.com/listing-1042/living-room.jpg", "https://photos.example.com/listing-1042/kitchen.jpg"], "listing": "L-1042", "long_edge": 2048, "format": "webp", "market": "malaysia"}' ``` ### JavaScript (Node 18 or later) ```js const response = await fetch("https://retouch.homes/api/v1/photos", { method: "POST", headers: { Authorization: `Bearer ${process.env.RETOUCH_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ photos: ["https://photos.example.com/listing-1042/living-room.jpg", "https://photos.example.com/listing-1042/kitchen.jpg"], listing: "L-1042", long_edge: 2048, format: "webp", market: "malaysia", }), signal: AbortSignal.timeout(300_000), }); const body = await response.json(); if (!response.ok) throw new Error(`${body.error.code}: ${body.error.message}`); for (const photo of body.photos) { if (photo.status === "succeeded") console.log(photo.output_url, photo.flagged ? "(check it)" : ""); else if (photo.status === "failed") console.error(photo.source_url, photo.error.message); else console.log(photo.id, "is still processing; GET it later"); } ``` ### Python ```python import os import requests response = requests.post( "https://retouch.homes/api/v1/photos", headers={"Authorization": f"Bearer {os.environ['RETOUCH_API_KEY']}"}, json={ "photos": ["https://photos.example.com/listing-1042/living-room.jpg", "https://photos.example.com/listing-1042/kitchen.jpg"], "listing": "L-1042", "long_edge": 2048, "format": "webp", "market": "malaysia", }, timeout=300, ) body = response.json() if not response.ok: raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}") for photo in body["photos"]: if photo["status"] == "succeeded": print(photo["output_url"], "(check it)" if photo["flagged"] else "") elif photo["status"] == "failed": print(photo["source_url"], photo["error"]["message"]) else: print(photo["id"], "is still processing; GET it later") ``` ### Response The `report` here is abridged. ```json { "photos": [ { "id": "photo_01kfz3m8q2e4t6v8w0y2a4c6e8", "object": "photo", "status": "succeeded", "source_url": "https://photos.example.com/listing-1042/living-room.jpg", "listing": "L-1042", "output_url": "https://omunafaeewioeslh.public.blob.vercel-storage.com/photos/photo_01kfz3m8q2e4t6v8w0y2a4c6e8/living-room-Xb3kQ9.webp", "flagged": false, "confidence": 0.86, "report": { "file": "living-room.jpg", "input_size": [ 4032, 3024 ], "output_size": [ 2048, 1536 ], "distortions_found": { "camera_roll": { "degrees": -1.4, "severity": "moderate" }, "vertical_keystone": { "degrees": 2.1, "direction": "camera aimed down", "severity": "moderate" } }, "corrections_applied": { "tilt_correction_deg": 2.1, "crop_retained_pct": 94.2 }, "confidence": 0.86 }, "error": null, "created": 1794240000 }, { "id": "photo_01kfz3m8q2e4t6v8w0y2a4c6ea", "object": "photo", "status": "failed", "source_url": "https://photos.example.com/listing-1042/kitchen.jpg", "listing": "L-1042", "output_url": null, "flagged": null, "confidence": null, "report": null, "error": { "type": "photo_error", "code": "photo_failed", "message": "photo is larger than 25 MB" }, "created": 1794240000 } ] } ``` The request returns 200 even when some photos fail. Check each photo's `status`: - `succeeded`: `output_url` is the edited photo, public. `report` says what was found and changed. - `failed`: `error.message` says why, e.g. `photo host … is not allowed` or `photo is larger than 25 MB`. Photos left when the time budget runs out fail too; send them again. - `processing`: another request is editing this photo. Read it later with `GET /api/v1/photos/{id}`. `flagged` is true when `confidence` is below 0.4: look at that photo before you publish it. ## Get a photo ```bash curl https://retouch.homes/api/v1/photos/photo_01kfz3m8q2e4t6v8w0y2a4c6e8 \ -H "Authorization: Bearer $RETOUCH_API_KEY" ``` Returns one photo object of your organization, or 404 `resource_missing`. ## Retries Retries are safe without an idempotency key. A URL your organization already sent, with the same `long_edge`, `format` and window recovery, is the same photo: a finished one comes back as it is and isn't edited or counted again, and a failed one is tried again. The same URL with another `long_edge` or `format`, or with window recovery turned on by `market`, is another photo. The URL is matched as sent, query string included; `source_url` shows it without the query string. ## Errors Every error has one shape: ```json {"error": {"type": "invalid_request_error", "code": "parameter_invalid", "message": "Each photo must be an http(s) URL.", "param": "photos[0]", "doc_url": null}} ``` | Status | type | code | When | |---|---|---|---| | 400 | `invalid_request_error` | `invalid_json` | The body isn't a JSON object. | | 400 | `invalid_request_error` | `parameter_missing` | `photos` is missing or empty. | | 400 | `invalid_request_error` | `too_many_photos` | More than 40 photos. | | 400 | `invalid_request_error` | `parameter_invalid` | A field is malformed; `param` names it, e.g. `photos[2]` or `long_edge`. | | 401 | `authentication_error` | `missing_api_key` | No `Authorization: Bearer` header. | | 401 | `authentication_error` | `invalid_api_key` | The key is unknown or revoked. | | 402 | `invalid_request_error` | `insufficient_credits` | Your organization has fewer photos left than the request needs. | | 402 | `invalid_request_error` | `plan_unknown` | Your organization has no active plan. | | 404 | `invalid_request_error` | `resource_missing` | No photo with that ID in your organization (GET only). | | 429 | `rate_limit_error` | `rate_limited` | Over 60 requests a minute per key. Wait `Retry-After` seconds. | | 500 | `api_error` | `internal_error` | Something failed on our side. Retry. | | 502 | `api_error` | `engine_unavailable` | Photo processing is unavailable. Retry. | Retry 429, 500 and 502. Don't retry 400, 401, 402 or 404 without changing the request. ## Limits - 40 photos per request. - Each photo: JPEG, PNG, WebP and TIFF, at most 25 MB and 25 megapixels, downloaded within 20 seconds. - Photo hosts: an allowlist during the beta (above). Private and loopback addresses are never fetched, redirects included. - 60 requests a minute per key. - We keep a private copy of each input for 90 days, then delete it. Edited photos stay at their `output_url`. ## Pricing Free during the beta: nothing is charged yet. Once billing starts, these prices apply. One edited photo uses one photo from your balance, in the app and through the API alike. Each organization gets 10 photos free, then buys packs at https://retouch.homes/app/billing: - 30 photos: $12, $0.40 a photo - 300 photos: $90, $0.30 a photo Prices in USD. Photos never expire. A photo already edited costs nothing when sent again. With no photos left, the API answers 402 `insufficient_credits`.