Reference: Jobs and Keyframes

Quotes, starting, following, cancelling and deleting jobs, downloading their results, and reviewing the keyframes of a colorization.

Jobs

Start, follow, cancel and delete jobs, and download their results.

POST /jobs #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope jobs:write

Starts a job: a ready media is processed with your settings on our full pipeline (restoration, Reconstruct, colorization with your review of the keyframes, frame interpolation, sound restoration). The price is taken from your balance at this moment and given back once if the job does not deliver.

Parameters
NameWhereTypeRequiredWhat it is
Idempotency-Keyheaderstringno (recommended)Your own value of 1 to 64 printable characters: a new random one (a UUID) for every new request, and the same one when you send that request again after a lost response. The shell examples keep it in a variable that a second run of the same block does not change (see Idempotency keys). Example: 9b2f0c7e-3d4a-4f61-8a5e-2c1b7d9e0f43.
media_idbody (JSON)stringyesThe ready media to process. Example: med_4821.
settingsbody (JSON)objectyesWhat the job does: either preset_id or filters (not both), optionally output_format and output_scaling.
settings.preset_idbody (JSON)stringone of the twoA preset of GET /catalogue (presets[].id): its filters, output format and output scaling. Example: pre_90. A preset's upload server (a website setting) is never used by an API job: the result stays here for you to download.
settings.filtersbody (JSON)array of stringsone of the twoFilter keys of GET /catalogue (filters[].key; the full list is in Filters). At most one of light_dust_and_scratches, medium_dust_and_scratches and heavy_dust_and_scratches. An empty list [] runs no filter: the job only re-encodes the media into the output format (and scales it, with output_scaling), at the full price of its tier. Example: ["noise", "medium_dust_and_scratches"].
settings.output_formatbody (JSON)stringnoThe result's file type: mov_prores_422_hq, mov_prores_4444, mxf_dnxhr_hqx, mxf_dnxhr_444, mp4_h264 or mp4_hevc. Default: the preset's format, or mov_prores_422_hq with filters.
settings.output_scalingbody (JSON)stringnoThe result's height: source (the same as the media), 1080 (HD) or 2160 (4K). Default: the preset's scaling, or source with filters. A larger output can raise the price (see Billing).
outputbody (JSON)objectnoHow the result fits back into an editing timeline. Every part has a default; see Output options.
output.geometrybody (JSON)stringnorestored or match_source. Default: restored.
output.frame_ratebody (JSON)stringnoprocessed or source. Default: processed.
output.audiobody (JSON)stringnosource (the sound as it is - the preset's sound filters are left out), restored (the sound filters of the settings run) or none (no sound). Default: source.
output.timecodebody (JSON)stringnoOnly source (the media's own timecode). Default: source.
output.reel_namebody (JSON)stringno1 to 32 printable ASCII characters (letters, digits, spaces, punctuation), written as the reel name of the result's timecode track. Default: none. Example: A001.
output.formatbody (JSON)stringnoThe same as settings.output_format; when both are sent, this one counts.
output.scalingbody (JSON)stringnoThe same as settings.output_scaling; when both are sent, this one counts.
keyframesbody (JSON)stringnoColorization only: review (the job waits for you to look at the colorized keyframes - see Keyframes) or auto (they are approved as they are). Default: review.
referencebody (JSON)stringnoYour own name for the job, up to 100 characters; it comes back unchanged. Example: Shot 12.
client_databody (JSON)objectnoAnything your program wants to find again with the job, up to 4 KB as JSON; it comes back unchanged. Example: {"sequence": "Main"}.
notify_urlbody (JSON)stringnoA public https address of yours for webhooks about this job. API keys only.
rangebody (JSON)nullnoLeave it out (parts of a media are not supported yet: upload the part you want).

What you get back: 201 Created with a Job object (status queued), a Location header (the job's address) and Retry-After: 1. Then follow it with GET /jobs/{job_id}.

Good to know: A media can have one job at a time: a second start answers JobExistsForMedia with the existing job's id in details.job_id. To run other settings on the same media, delete the first job (after downloading its result). Send an Idempotency-Key: if the response is lost and you send the request again with the same key, you get the same job instead of a second one (and a second charge). These combinations cannot be made and answer OptionConflict with the reason in detail: two dust and scratches levels; an MXF output at a frame rate MXF cannot hold (for example 16 or 18 fps) or smaller than 256×120 pixels; MXF for a sound-only media; output.audio none for a sound-only media; a sound-only media whose sound is silent; match_source with an output scaling or with a stretched or squeezed picture; output.frame_rate source with natural speed or high frame rate.

Request
KEY_JOBS=${KEY_JOBS:-$(openssl rand -hex 16)}
curl -sS -u "$AUTH" "$API/jobs" -H "Content-Type: application/json" -H "Idempotency-Key: $KEY_JOBS" \
     -d '{"media_id": "med_4821",
          "settings": {"filters": ["noise", "medium_dust_and_scratches"], "output_format": "mov_prores_422_hq"},
          "output": {"geometry": "match_source", "frame_rate": "source", "audio": "none", "reel_name": "A001"},
          "reference": "Shot 12", "client_data": {"sequence": "Main", "track_item": "V1:12"}}'
Response: 201 Created
{
  "id": "job_3150",
  "media_id": "med_4821",
  "status": "queued",
  "stage": null,
  "needs_action": null,
  "progress": { "percent": 0.0, "stage_percent": null, "eta_seconds": null },
  "queue": { "position": 0, "message": null },
  "settings": { "preset_id": null, "filters": ["noise", "medium_dust_and_scratches"] },
  "output": { "geometry": "match_source", "frame_rate": "source", "audio": "none", "timecode": "source", "reel_name": "A001",
              "format": "mov_prores_422_hq", "scaling": "source" },
  "price": { "amount": "0.80", "currency": "EUR" },
  "billing": { "state": "reserved", "charged": { "amount": "0.80", "currency": "EUR" } },
  "watermark": false,
  "keyframes": "review",
  "created_at": "2026-10-06T09:04:02Z",
  "started_at": null,
  "finished_at": null,
  "result": null,
  "error": null,
  "reference": "Shot 12",
  "client_data": { "sequence": "Main", "track_item": "V1:12" }
}

Error codes it can answer (each links to what it means and what to do): ValidationFailed, NotFound, MediaNotReady, PictureTooLargeForFilter, JobExistsForMedia, BalanceInsufficient, SecondsLeftInsufficient, TrialLimitExceeded, OptionConflict, FrameRateChangedAtIngest, RangeNotSupported, IdempotencyKeyReused, ExchangeRateUnavailable, InsufficientStorage, InsufficientScope. Any endpoint can also answer RateLimited and InternalError.

GET /jobs/{job_id} #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope jobs:read

Shows one job: its status and stage, its progress, its place in the queue, its price and billing, and - once it is completed - its result. Poll it to follow a job.

Parameters
NameWhereTypeRequiredWhat it is
job_idpathstringyesThe job's id, as POST /jobs or GET /jobs gave it. Example: job_3150.
If-None-MatchheaderstringnoThe last ETag (a free 304 when nothing has changed).

What you get back: 200 OK with a Job object, an ETag and a Retry-After header: 1 while the job is queued or running, 60 otherwise (also while it waits for your keyframes).

Good to know: How long a job takes depends on the length of the media, its frame size, the filters and the queue; progress.eta_seconds gives the estimated seconds left once we can tell.

Request
curl -sS -u "$AUTH" "$API/jobs/job_3150"
Response: 200 OK
{
  "id": "job_3150",
  "media_id": "med_4821",
  "status": "completed",
  "stage": null,
  "needs_action": null,
  "progress": { "percent": 100.0, "stage_percent": null, "eta_seconds": null },
  "queue": { "position": 0, "message": null },
  "settings": { "preset_id": null, "filters": ["noise", "medium_dust_and_scratches"] },
  "output": { "geometry": "match_source", "frame_rate": "source", "audio": "none", "timecode": "source", "reel_name": "A001",
              "format": "mov_prores_422_hq", "scaling": "source" },
  "price": { "amount": "0.80", "currency": "EUR" },
  "billing": { "state": "settled", "charged": { "amount": "0.80", "currency": "EUR" } },
  "watermark": false,
  "keyframes": "review",
  "created_at": "2026-10-06T09:04:02Z",
  "started_at": "2026-10-06T09:04:04Z",
  "finished_at": "2026-10-06T09:04:18Z",
  "result": {
    "url": "https://www.recuromedia.com/api/1.0/jobs/job_3150/result",
    "file_name": "A001_C003 - Custom - job_3150.mov",
    "size": 36286072,
    "sha256": "2ca1e869a30b079fc77aec531a5fdf16736c727dff5dff91e9bffa3e2cbffa90",
    "content_type": "video/quicktime",
    "expires_at": "2026-12-05T09:04:18Z"
  },
  "error": null,
  "reference": "Shot 12",
  "client_data": { "sequence": "Main", "track_item": "V1:12" }
}

Error codes it can answer (each links to what it means and what to do): NotFound. Any endpoint can also answer RateLimited and InternalError.

GET /jobs #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope jobs:read

Lists your jobs, newest first, a page at a time. Use it to find your jobs again after your program restarted - with the reference and client_data you gave them.

Parameters
NameWhereTypeRequiredWhat it is
statusquerystringnoactive (queued, running or waiting for keyframes), finished (completed, failed or cancelled) or all. Default: all.
media_idquerystringnoOnly the jobs of this media. A media that is not yours answers NotFound.
limitqueryintegernoHow many entries one page holds: 1 to 200. Default: 50.
cursorquerystringnoThe next_cursor of the page before, exactly as it came. Leave it out for the first page. See Lists and pages.

What you get back: 200 OK with data (a list of Job objects) and next_cursor (a string for the next page, or null on the last page).

Good to know: Deleted jobs are not listed.

Request
curl -sS -u "$AUTH" "$API/jobs?status=active"
Response: 200 OK
{ "data": [ { "id": "job_3150", "media_id": "med_4821", "status": "running", "stage": "restoring", "...": "..." } ], "next_cursor": null }

Error codes it can answer (each links to what it means and what to do): ValidationFailed, NotFound. Any endpoint can also answer RateLimited and InternalError.

POST /jobs/{job_id}/cancel #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope jobs:write

Stops a job that has not ended. What it was charged is given back (once). The job stays, with status cancelled, so you keep a record of it.

Parameters
NameWhereTypeRequiredWhat it is
job_idpathstringyesThe job's id, as POST /jobs or GET /jobs gave it. Example: job_3150.

What you get back: 202 Accepted with the Job object as it is now (status cancelled, billing.state refunded).

Good to know: A cancelled job still counts as the media's job: delete it (DELETE /jobs/{job_id}) before you start another job of the same media. A job that has ended already answers JobNotCancellable.

Request
curl -sS -u "$AUTH" --data '' "$API/jobs/job_3151/cancel"
Response: 202 Accepted
{ "id": "job_3151", "media_id": "med_4821", "status": "cancelled", "...": "...",
  "billing": { "state": "refunded", "charged": { "amount": "0.80", "currency": "EUR" } }, "finished_at": "2026-10-06T09:04:27Z" }

Error codes it can answer (each links to what it means and what to do): NotFound, JobNotCancellable. Any endpoint can also answer RateLimited and InternalError.

DELETE /jobs/{job_id} #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope jobs:write

Deletes a job: it is stopped if it still runs, its result file is deleted, and what it was charged without delivering is given back. The media stays.

Parameters
NameWhereTypeRequiredWhat it is
job_idpathstringyesThe job's id, as POST /jobs or GET /jobs gave it. Example: job_3150.

What you get back: 204 No Content: deleted; no body. Afterwards GET /jobs/{job_id} answers NotFound. This cannot be undone: download the result first.

Request
curl -sS -u "$AUTH" -X DELETE "$API/jobs/job_3150"
Response: 204 No Content
HTTP/2 204

Error codes it can answer (each links to what it means and what to do): NotFound. Any endpoint can also answer RateLimited and InternalError.

GET /jobs/{job_id}/result #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope jobs:read

Downloads the finished file of a completed job. An interrupted download can be resumed where it stopped.

Parameters
NameWhereTypeRequiredWhat it is
job_idpathstringyesThe job's id, as POST /jobs or GET /jobs gave it. Example: job_3150.
RangeheaderstringnoOne byte range, to resume: bytes=1048576- (from byte 1048576 to the end), bytes=0-1023 (the first 1024 bytes) or bytes=-1024 (the last 1024 bytes). curl -C - sends it for you.
If-RangeheaderstringnoThe result's ETag: the range counts only while the file is still the same; otherwise the whole file comes (200).
If-None-MatchheaderstringnoThe result's ETag: 304 when you have this file already.

What you get back: 200 OK with the whole file, or 206 Partial Content with the range you asked for (Content-Range says which bytes). The headers: Content-Type (video/quicktime, application/mxf or video/mp4), Content-Length (bytes), ETag, Accept-Ranges: bytes and Content-Disposition with the file name - an ASCII filename= and, for a name with other characters, also filename*=UTF-8''... (RFC 6266); use that one when it is there. Cache-Control: private, no-cache: no shared cache (a company proxy) keeps the file. HEAD gives the headers without the file.

Good to know: With curl --fail an error answer (for example ResultNotReady) is never saved as the file: curl ends with exit code 22, and the example then deletes what it had saved - it is not your result. A broken connection ends with another exit code and keeps the part: run the same line again and -C - continues it. Downloads have no per-minute limit. After the download, compare the file's SHA-256 with result.sha256 of the job (given for jobs started through the API; null for a job started in the workspace). The result is kept 60 days after the job finished (result.expires_at).

Request
curl -sS --fail -u "$AUTH" -C - -o restored.mov "$API/jobs/job_3150/result" || { [ $? -eq 22 ] && rm -f restored.mov; }
Response: 206 Partial Content (with Range)
HTTP/2 206
content-type: video/quicktime
content-range: bytes 0-1023/36286072
accept-ranges: bytes
etag: "5b3359ff-4da5-4a20-a9ec-7a79c54f2883-36286072-1791277457"
content-disposition: attachment; filename="A001_C003 - Custom - job_3150.mov"
content-length: 1024

Error codes it can answer (each links to what it means and what to do): NotFound, ResultNotReady, NoResult, ResultExpired, RangeNotSatisfiable. Any endpoint can also answer RateLimited and InternalError.

POST /jobs/{job_id}/result-links #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope jobs:read

Makes a download address for the result that works without a key - for a colleague's browser, a media asset manager or a transcoder - until the time you choose.

Parameters
NameWhereTypeRequiredWhat it is
job_idpathstringyesThe job's id, as POST /jobs or GET /jobs gave it. Example: job_3150.
expires_inbody (JSON)integernoHow long the address works, in seconds: 60 to 86400 (24 hours). Default: 3600 (1 hour). The body can be empty.

What you get back: 201 Created with url (the address; anyone who has it can download the file until it expires - treat it like a password) and expires_at (when it stops working, UTC). The address supports Range, and its download has the same file name (Content-Disposition) and Cache-Control: private, no-cache as GET /jobs/{job_id}/result.

Good to know: Available when GET /health says features.signed_result_links true; otherwise FeatureUnavailable. The address is always on www.recuromedia.com, also when your files are on a cloud server: while that server is starting, the address answers 503 Service Unavailable with Retry-After - try again then.

Request
curl -sS -u "$AUTH" "$API/jobs/job_3150/result-links" -H "Content-Type: application/json" -d '{"expires_in": 600}'
Response: 201 Created
{
  "url": "https://www.recuromedia.com/download.php?guid=5b3359ff-4da5-4a20-a9ec-7a79c54f2883&exp=1791278062&sig=f271c06fe4d2903aa76d4abe4f3f65c0fec0412057b8be7d113f5fbc8ccf0a3f",
  "expires_at": "2026-10-06T09:14:22Z"
}

Error codes it can answer (each links to what it means and what to do): ValidationFailed, ResultNotReady, NoResult, FeatureUnavailable, NotFound. Any endpoint can also answer RateLimited and InternalError.

Keyframes

Only for colorization jobs that wait for your review of the keyframes (status needs_action). The process is explained in Keyframes.

GET /jobs/{job_id}/keyframes #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope jobs:read

Lists the colorized keyframes of a job that waits for your review, scene by scene, with the addresses of their images and your current choices.

Parameters
NameWhereTypeRequiredWhat it is
job_idpathstringyesThe job's id, as POST /jobs or GET /jobs gave it. Example: job_3150.

What you get back: 200 OK with a Keyframes object.

Good to know: Only while the job waits for them (status needs_action, needs_action keyframes); otherwise JobNotAwaitingKeyframes.

Request
curl -sS -u "$AUTH" "$API/jobs/job_3152/keyframes"
Response: 200 OK
{
  "saturation": 100,
  "scenes": [
    { "scene": 0, "first_frame": 0, "last_frame": 49,
      "coverage": { "percent": 98.5, "uncovered_frames": 1 },
      "keyframes": [
        { "frame": 12, "image_url": "https://www.recuromedia.com/api/1.0/jobs/job_3152/keyframes/12.jpg",
          "full_url": "https://www.recuromedia.com/api/1.0/jobs/job_3152/keyframes/12.png",
          "original_url": "https://www.recuromedia.com/api/1.0/jobs/job_3152/keyframes/12/original.jpg", "saturation": 100, "excluded": false, "shown": true,
          "active_version": 1, "versions": [ { "version": 1, "text": null, "...": "..." } ],
          "modify": { "available": true, "state": "idle", "limit": 5, "used": 0, "remaining": 5, "per": "keyframe", "...": "..." } },
        { "frame": 37, "image_url": "https://www.recuromedia.com/api/1.0/jobs/job_3152/keyframes/37.jpg",
          "full_url": "https://www.recuromedia.com/api/1.0/jobs/job_3152/keyframes/37.png",
          "original_url": "https://www.recuromedia.com/api/1.0/jobs/job_3152/keyframes/37/original.jpg", "saturation": 100, "excluded": false, "shown": true,
          "...": "..." }
      ] }
  ]
}

Error codes it can answer (each links to what it means and what to do): NotFound, JobNotAwaitingKeyframes. Any endpoint can also answer RateLimited and InternalError.

GET /jobs/{job_id}/keyframes/{frame}.{ext} #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope jobs:read

Downloads the image of one keyframe as it will be used (its active version).

Parameters
NameWhereTypeRequiredWhat it is
job_idpathstringyesThe job's id, as POST /jobs or GET /jobs gave it. Example: job_3150.
framepathintegeryesThe keyframe's frame number (counted from 0), as frame in GET /jobs/{job_id}/keyframes. Example: 12.
extpathstringyesjpg (a JPEG thumbnail, 480 pixels wide) or png (a PNG at full size).

What you get back: 200 OK with the image (image/jpeg or image/png), cached by your client for up to 60 seconds.

Request
curl -sS --fail -u "$AUTH" -o keyframe-12.png "$API/jobs/job_3152/keyframes/12.png" || rm -f keyframe-12.png
Response: 200 OK
HTTP/2 200
content-type: image/png

Error codes it can answer (each links to what it means and what to do): NotFound, JobNotAwaitingKeyframes. Any endpoint can also answer RateLimited and InternalError.

GET /jobs/{job_id}/keyframes/{frame}/original.jpg #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope jobs:read

Downloads the same frame before colorization - to show next to the keyframe as a before/after.

Parameters
NameWhereTypeRequiredWhat it is
job_idpathstringyesThe job's id, as POST /jobs or GET /jobs gave it. Example: job_3150.
framepathintegeryesThe keyframe's frame number (counted from 0), as frame in GET /jobs/{job_id}/keyframes. Example: 12.

What you get back: 200 OK with a JPEG of the same size as the keyframe's thumbnail (480 pixels wide), cropped as the keyframe.

Good to know: The frame numbers are the media's frames as analysed: a 29.97 fps clip with 3:2 pulldown was turned into 23.976 fps at the analysis, so its frames are counted at 23.976 fps. A frame that is not one of the job's keyframes answers NotFound.

Request
curl -sS --fail -u "$AUTH" -o original-12.jpg "$API/jobs/job_3152/keyframes/12/original.jpg" || rm -f original-12.jpg
Response: 200 OK
HTTP/2 200
content-type: image/jpeg

Error codes it can answer (each links to what it means and what to do): NotFound, JobNotAwaitingKeyframes. Any endpoint can also answer RateLimited and InternalError.

PUT /jobs/{job_id}/keyframes #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope keyframes:write

Saves your choices for the keyframes: the colour strength (saturation) for the whole job and per keyframe, which keyframes to leave out, and which colour version of a keyframe to use. What you do not name stays as it is.

Parameters
NameWhereTypeRequiredWhat it is
job_idpathstringyesThe job's id, as POST /jobs or GET /jobs gave it. Example: job_3150.
saturationbody (JSON)integernoThe colour strength of the whole job in percent: 50 to 200 (100 = as colorized). It applies to every keyframe without its own saturation.
keyframesbody (JSON)objectnoPer keyframe: the frame number as a string (the key) and an object with any of the three entries below. Example: {"12": {"saturation": 120}}.
keyframes.<frame>.saturationbody (JSON)integernoThis keyframe's colour strength in percent: 50 to 200.
keyframes.<frame>.excludedbody (JSON)booleannotrue leaves the keyframe out; false uses it again. Every scene keeps at least one keyframe (SceneNeedsKeyframe).
keyframes.<frame>.versionbody (JSON)integernoWhich colour version to use: one of the keyframe's versions (1 is the colorization; see Modify). Free, any number of times.

What you get back: 200 OK with the Keyframes object as saved.

Good to know: Nothing is checked out or locked: you can save as often as you like before you approve.

Request
curl -sS -u "$AUTH" -X PUT "$API/jobs/job_3152/keyframes" -H "Content-Type: application/json" \
     -d '{"saturation": 110, "keyframes": {"12": {"saturation": 120}, "37": {"excluded": true}}}'
Response: 200 OK
{ "saturation": 110, "scenes": [ { "scene": 0, "...": "...", "keyframes": [ { "frame": 12, "saturation": 120, "excluded": false, "...": "..." },
                                                                           { "frame": 37, "saturation": 110, "excluded": true, "...": "..." } ] } ] }

Error codes it can answer (each links to what it means and what to do): ValidationFailed, SceneNeedsKeyframe, JobNotAwaitingKeyframes, NotFound. Any endpoint can also answer RateLimited and InternalError.

POST /jobs/{job_id}/keyframes/approve #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope keyframes:write

Approves the keyframes: the job carries their colours to every frame and runs on to the end. This is the workspace's FINISH JOB. You can send last choices in the same request.

Parameters
NameWhereTypeRequiredWhat it is
job_idpathstringyesThe job's id, as POST /jobs or GET /jobs gave it. Example: job_3150.
(the body)body (JSON)objectnoEmpty, or choices in the same form as PUT /jobs/{job_id}/keyframes; they are saved first.

What you get back: 202 Accepted with the Job object (status running, stage propagating). Follow it with GET /jobs/{job_id} as before.

Good to know: The active version of every keyframe is used - also a picture our check rejected that you chose (attention rejected: it is your choice). While a keyframe is being changed (Modify), approving answers ModifyInProgress with Retry-After: 1: wait until the change is done or failed. While a keyframe you use has no picture at all (needs_choice true), approving answers KeyframeNeedsChoice: choose another version, change it or leave it out first.

Request
curl -sS -u "$AUTH" --data '' "$API/jobs/job_3152/keyframes/approve"
Response: 202 Accepted
{ "id": "job_3152", "status": "running", "stage": "propagating", "needs_action": null, "...": "..." }

Error codes it can answer (each links to what it means and what to do): JobNotAwaitingKeyframes, ModifyInProgress, SceneNeedsKeyframe, KeyframeNeedsChoice, ValidationFailed, NotFound. Any endpoint can also answer RateLimited and InternalError.

POST /jobs/{job_id}/keyframes/{frame}/modify #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope keyframes:write

Has one keyframe's colours changed as you describe them in a sentence, for example make all uniforms dark green. The result becomes a new version of the keyframe and the one in use. A keyframe keeps a number of tries at a time by plan (Free, Prepaid and Invoice 5 tries per keyframe at a time, Trial none (Modify is not included)); on the Prepaid plan each try is a small charge from the balance; on the Free and Invoice plans tries are not charged. One change can go to every keyframe of the scene (scene).

Parameters
NameWhereTypeRequiredWhat it is
job_idpathstringyesThe job's id, as POST /jobs or GET /jobs gave it. Example: job_3150.
framepathintegeryesThe keyframe's frame number (counted from 0), as frame in GET /jobs/{job_id}/keyframes. Example: 12.
Idempotency-Keyheaderstringno (recommended)Your own value of 1 to 64 printable characters: a new random one (a UUID) for every new request, and the same one when you send that request again after a lost response. The shell examples keep it in a variable that a second run of the same block does not change (see Idempotency keys). Example: 9b2f0c7e-3d4a-4f61-8a5e-2c1b7d9e0f43.
textbody (JSON)stringyesWhat to change, as plain text on one line: 1 to 300 characters after spaces at the start and end are removed; no line breaks or control characters; the colour described in words, not as a code. Example: make all uniforms dark green.
scenebody (JSON)booleannotrue: the same change for every keyframe of this keyframe's scene that is not left out - each from its own active version, all started at once; it counts as ONE change of the scene. Default false: this keyframe only.
confirm_warningbody (JSON)booleannoAccepted and ignored (since 22 Oct 2026 a warning of the check never stops a change - it comes back as modify.warning).

What you get back: 202 Accepted with the Keyframe object (modify.state running), a Location header (the address to poll) and Retry-After: 1. Poll GET on the same address every second until modify.state is done or failed; in our tests a change took 7 to 10 seconds.

Good to know: Our AI image service edits the keyframe's active version: only what the text asks for changes; the other colours, the light and the picture stay - and every colour keeps the film's own brightness (a light thing stays light, a dark one dark: its colour can change, not how light it is). Before anything is started an AI check reads the text with the keyframe(s): a text that asks for something other than a colour change (adding or removing things, light or darkness, faces, fixing the picture, text, a whole style) or names something the picture does not show is refused with ModifyNotPossible (details.reason, in the language of the text; not counted); a colour much darker or lighter than the thing is in the film only gives a warning - the change is made and modify.warning holds the sentence to show (it comes out lighter or darker); describe the colour in words (for example a soft lavender purple) - that gives the most natural result: a colour code such as #5F3AB1 is not passed on, the check describes it in words (modify.sent_text), and a code alone with nothing named is refused (ModifyNotPossible). Every picture a change gives a keyframe is one try - also one our check rejected (it is in versions, marked); a request that gives no picture (a refusal, a failure, a cancel) is none. A keyframe keeps its plan's tries at a time (Free, Prepaid and Invoice 5 tries per keyframe at a time, Trial none (Modify is not included); modify.limit and modify.kept_max, modify.used and modify.kept the tries it keeps now): a change of a keyframe that keeps them all - also one for the whole scene when any keyframe it would change does - is refused before the check with KeyframeTriesFull; delete one first (DELETE .../versions/{version}) - that makes room. On the Trial plan Modify is not included (modify.included false, modify.available false): the request answers ModifyNotIncluded before anything is checked. Charges: on the Prepaid plan each try is a small charge from the balance; on the Free and Invoice plans tries are not charged (modify.charged; whether a try has been charged is versions[].charge, and each charged try is listed in the order history of the website). A request that gives no picture - a refusal of the check, of the content rules, a failure - is never charged. On the Prepaid plan the balance must hold EUR 0.05 for each keyframe the change goes to, or the request answers BalanceInsufficient before anything is checked. Any keyframe of the job can be changed, also one you left out - only while the job waits for its keyframes, and one change of a job at a time (ModifyInProgress - also while a check runs). A refusal or failure of the image service is not an HTTP error: the keyframe then shows modify.state failed with the reason. When this site does not offer changes (modify.available false) the request answers FeatureUnavailable with details.reason not_enabled (changes are switched off on this site) or not_configured (this site's own wording for changes is not set up) and no Retry-After - do not retry, and do not offer the change in your program then; when no service can make the change at the moment, details.reason is no_worker with Retry-After: 60; when the check could not be made in time (15 seconds), details.reason is precheck_failed with Retry-After: 5 - try again.

Request
KEY_MODIFY=${KEY_MODIFY:-$(openssl rand -hex 16)}
curl -sS -u "$AUTH" "$API/jobs/job_3152/keyframes/12/modify" -H "Content-Type: application/json" -H "Idempotency-Key: $KEY_MODIFY" \
     -d '{"text": "make all uniforms dark green"}'
Response: 202 Accepted
{ "frame": 12, "image_url": "https://www.recuromedia.com/api/1.0/jobs/job_3152/keyframes/12.jpg", "...": "...",
  "active_version": 1,
  "versions": [ { "version": 1, "text": null, "created": "2026-10-15T09:12:40Z",
                  "image_url": "https://www.recuromedia.com/api/1.0/jobs/job_3152/keyframes/12/versions/1.jpg",
                  "full_url": "https://www.recuromedia.com/api/1.0/jobs/job_3152/keyframes/12/versions/1.png" } ],
  "modify": { "available": true, "state": "running", "limit": 5, "used": 0, "remaining": 5, "per": "keyframe", "last_text": "make all uniforms dark green", "version": null,
              "error_code": null, "error": null, "started": "2026-10-15T09:20:02Z", "finished": null, "phase": "queued", "frames": [12],
              "sent_text": "Make all the uniforms dark green", "text_max": 200, "scene": { "scene": 0, "limit": 5, "used": 0, "remaining": 10, "per": "keyframe" } } }

Error codes it can answer (each links to what it means and what to do): ValidationFailed, NotFound, JobNotAwaitingKeyframes, ModifyInProgress, ModifyNotIncluded, KeyframeTriesFull, BalanceInsufficient, ModifyNotPossible, IdempotencyKeyReused, FeatureUnavailable. Any endpoint can also answer RateLimited and InternalError.

GET /jobs/{job_id}/keyframes/{frame}/modify #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope jobs:read

Shows one keyframe with its colour versions and the state of its last change - poll it while a change runs.

Parameters
NameWhereTypeRequiredWhat it is
job_idpathstringyesThe job's id, as POST /jobs or GET /jobs gave it. Example: job_3150.
framepathintegeryesThe keyframe's frame number (counted from 0), as frame in GET /jobs/{job_id}/keyframes. Example: 12.
If-None-MatchheaderstringnoThe last ETag (a free 304 when nothing has changed).

What you get back: 200 OK with a Keyframe object, an ETag and a Retry-After header (1 while a change runs, 60 otherwise).

Request
curl -sS -u "$AUTH" "$API/jobs/job_3152/keyframes/12/modify"
Response: 200 OK
{ "frame": 12, "image_url": "https://www.recuromedia.com/api/1.0/jobs/job_3152/keyframes/12.jpg?v=2", "...": "...", "active_version": 2,
  "versions": [ { "version": 1, "text": null, "...": "..." }, { "version": 2, "text": "make all uniforms dark green", "...": "..." } ],
  "modify": { "available": true, "state": "done", "limit": 5, "used": 1, "remaining": 4, "per": "keyframe", "last_text": "make all uniforms dark green", "version": 2,
              "scene": { "scene": 0, "limit": 5, "used": 1, "remaining": 9, "per": "keyframe" }, "...": "..." } }

Error codes it can answer (each links to what it means and what to do): NotFound, JobNotAwaitingKeyframes. Any endpoint can also answer RateLimited and InternalError.

GET /jobs/{job_id}/keyframes/{frame}/versions/{version}.{ext} #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope jobs:read

Downloads the image of one colour version of a keyframe - to show the versions side by side before you choose one.

Parameters
NameWhereTypeRequiredWhat it is
job_idpathstringyesThe job's id, as POST /jobs or GET /jobs gave it. Example: job_3150.
framepathintegeryesThe keyframe's frame number (counted from 0), as frame in GET /jobs/{job_id}/keyframes. Example: 12.
versionpathintegeryesThe version number, 1 or more (one of the keyframe's versions).
extpathstringyesjpg (a JPEG thumbnail, 480 pixels wide) or png (a PNG at full size).

What you get back: 200 OK with the image. A version never changes, so it is cached by your client for up to 3600 seconds (Cache-Control: private, max-age=3600).

Good to know: To use another version, send {"keyframes": {"12": {"version": 1}}} with PUT /jobs/{job_id}/keyframes - free, also on a keyframe that keeps its most tries and on the Trial plan. Approving carries the colours of each keyframe's active version to every frame.

Request
curl -sS --fail -u "$AUTH" -o keyframe-12-v1.png "$API/jobs/job_3152/keyframes/12/versions/1.png" || rm -f keyframe-12-v1.png
Response: 200 OK
HTTP/2 200
content-type: image/png

Error codes it can answer (each links to what it means and what to do): NotFound, JobNotAwaitingKeyframes. Any endpoint can also answer RateLimited and InternalError.

DELETE /jobs/{job_id}/keyframes/{frame}/versions/{version} #

Credential: API key and secret (HTTP Basic) or an access token (Bearer) · scope keyframes:write

Deletes one Modify try of a keyframe. A keyframe keeps its plan's tries at a time (Free, Prepaid and Invoice 5 tries per keyframe at a time, Trial none (Modify is not included)); deleting one makes room for the next change.

Parameters
NameWhereTypeRequiredWhat it is
job_idpathstringyesThe job's id, as POST /jobs or GET /jobs gave it. Example: job_3150.
framepathintegeryesThe keyframe's frame number (counted from 0), as frame in GET /jobs/{job_id}/keyframes. Example: 12.
versionpathintegeryesOne of the keyframe's versions with deletable true (a change's picture, also one our check rejected).

What you get back: 200 OK with the Keyframe object as GET .../keyframes/{frame}/modify shows it, without the version.

Good to know: The version and its pictures are gone for good, and its number is never used again. Its place is free again (modify.used and modify.kept one less, modify.remaining one more). A try on the Prepaid plan was charged when it was made: deleting it gives no money back. When it was the version in use, original_version - the keyframe's own colorization - is used instead. The colorization's own version and its tries cannot be deleted (ValidationFailed); while a change of this keyframe runs or a check is under way the request answers ModifyInProgress with Retry-After: 1.

Request
curl -sS -u "$AUTH" -X DELETE "$API/jobs/job_3152/keyframes/12/versions/3"
Response: 200 OK
{ "frame": 12, "active_version": 2, "original_version": 1, "...": "...",
  "versions": [ { "version": 1, "text": null, "deletable": false, "...": "..." }, { "version": 2, "text": "make all uniforms dark green", "deletable": true, "...": "..." } ],
  "modify": { "used": 1, "remaining": 4, "kept": 1, "kept_max": 5, "...": "..." } }

Error codes it can answer (each links to what it means and what to do): ValidationFailed, NotFound, JobNotAwaitingKeyframes, ModifyInProgress. Any endpoint can also answer RateLimited and InternalError.