Concepts and Conventions

How the things of the API work and fit together, the rules every request and response follows, and the limits that protect the service.

Concepts

How the things of the API work and fit together.

Uploads and media

A file reaches us as an upload (ids start with upl_): you open it with the file's name and size, send the file in chunks in any order and up to 4 at once, and complete it. An open upload is kept 7 days after its last chunk. After the completion it is a media (med_) and is analysed: frame rate and size, black borders (letterbox or pillarbox), black-and-white or colour, scenes, interlacing, pulldown. With an API key you can also import a file from an https address (POST /media/imports).

Only what you upload is processed and charged. Our plugins upload just the part of the timeline that should be processed; parts of a longer media cannot be chosen yet (RangeNotSupported): cut the part before you upload it. defaults.handles_seconds of GET /me (1) is how many seconds an app adds before and after the part it cuts, so that the edit has room at both ends.

A 29.97 fps file with 3:2 pulldown (film transferred to NTSC video) is turned back into 23.976 fps at the analysis (warnings: pulldown_removed). To get a result at the file's own 29.97 fps, upload it with "ingest": {"keep_frame_rate": true}.

Resuming an interrupted upload

Film scans are large, and one plain HTTP upload of a large file cannot be resumed: when the connection breaks, the part that arrived is lost and you cannot ask how much it was. Our uploads work like Amazon S3 multipart uploads, Google's resumable uploads or tus:

  • an upload is a session on our server, kept 7 days after its last chunk;
  • the file goes in chunks (8 MiB unless you choose 4 to 32 MiB) in any order, up to 4 at once, each with its SHA-256 (Repr-Digest), so a damaged chunk is refused and sent again - and sending a chunk twice is harmless;
  • GET /uploads/{upload_id} lists the chunks still missing.

So a break costs at most the chunks that were on their way - even days later, after a laptop's sleep, a lost network or a restarted program: ask which chunks are missing, send those, complete. The whole file's SHA-256, given when the upload was opened, is checked after the completion. In the shell of the workflow (the same UPLOAD and chunk files). The first line is the upload of step 2 (kept 7 days after its last chunk). The block shows the upload ("received_count": 3, "missing": [3,4], ...), cuts the file into chunk files again when they are gone (the same file and chunk size: chunk_aaaa is chunk 0, chunk_aaab chunk 1, ...), sends the missing chunks and completes:

Resume an upload
UPLOAD=upl_5f0c2a9e41b7d3c8e6a1f094
curl -sS -u "$AUTH" "$API/uploads/$UPLOAD"; echo
[ -e chunk_aaaa ] || split -a 4 -b 8388608 "$FILE" chunk_
for INDEX in $(curl -sS -u "$AUTH" "$API/uploads/$UPLOAD" | grep -o '"missing":\[[0-9,]*\]' | grep -oE '[0-9]+'); do
  PART=$(ls chunk_* | sed -n "$((INDEX + 1))p")
  DIGEST=$(openssl dgst -sha256 -binary "$PART" | base64)
  curl -sS -u "$AUTH" -X PUT "$API/uploads/$UPLOAD/chunks/$INDEX" -H "Content-Type: application/octet-stream" \
       -H "Repr-Digest: sha-256=:$DIGEST:" --data-binary "@$PART"
  echo
done
rm chunk_*
curl -sS -u "$AUTH" --data '' "$API/uploads/$UPLOAD/complete"; echo

(missing lists up to 1,000 chunks - about 8 GB at 8 MiB: for a larger file, ask again until it is empty.) restore.py does all of this by itself, 4 chunks at a time.

Filters

A job runs the filters you choose (settings.filters) or those of a preset (settings.preset_id). The keys below are the ones GET /catalogue lists, each with a short description (the catalogue has the full one); the catalogue also links an example video for most of them. Every filter costs the same (see Billing).

KeyNameWhat it does
noiseNoiseRemoves noise that changes from frame to frame and makes the picture smoother.
re_grainRe-grainAdds film grain back after the restoration, for the look of film.
light_dust_and_scratchesLight dust & scratchesRemoves dust; suits fast-moving scenes such as sports.
medium_dust_and_scratchesMedium dust & scratchesRemoves most dust; suits most material.
heavy_dust_and_scratchesHeavy dust & scratchesRemoves almost all dust and mould, for badly damaged film.
color_balanceColor balanceEvens the white balance and removes discolouration of aged film.
color_saturationColor saturationRestores the colour saturation of scenes with too little or too much.
contrast_expansionContrast expansionIncreases the contrast, which brings out detail.
colorizeColorizeColorizes black-and-white film; the job then waits for your keyframe review (Keyframes).
image_stabilizationImage stabilizationRemoves fast shaking (frames jumping up and down).
reduce_flickerReduce flickerReduces the flickering of light between frames of old black-and-white film.
content_stabilizationContent stabilizationReduces the shakiness of an unsteady camera.
adaptive_sharpnessAdaptive sharpnessSharpens detail as much as each scene needs.
color_castingColor castingClears a strong colour haze over the whole film.
bad_scene_changesBad scene changesReplaces up to two bad frames at a cut between scenes.
reconstructReconstructReconstructs washed-out or blurry film before it is restored.
repair_deinterlaceRepair deinterlaceRepairs progressive film that was badly deinterlaced.
high_frame_rateHigh frame rateDoubles the frame rate for smoother motion. Changes the timing: not with output.frame_rate source.
vertical_scratchesVertical scratchesReduces long vertical scratches that run through many frames.
natural_speedNatural speedMakes silent film (16 fps scanned at 24 or 25 fps) move at its natural speed. Changes the timing: not with output.frame_rate source.
normalize_volumeNormalize volumeSound: raises the volume to a normal level without distortion.
reduce_noiseReduce noiseSound: reduces background noise.
increase_clarityIncrease claritySound: makes voices and details easier to hear.

Choose at most one of the three dust and scratches levels. An empty list ("filters": []) 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. The three sound filters run only with output.audio restored (the default source keeps the sound as it is).

Previews

A preview (prv_) is a free before/after image of one frame with the settings you consider - the website's image comparison. Up to 20 restoration and 5 colorization previews per company in 24 hours on every plan (the Free plan has no daily count), 2 being made at the same time per user. An app can upload just one second around the playhead for it (purpose preview; such uploads are deleted after 24 hours unless a job uses them).

Jobs

A job (job_) processes one ready media with a preset or a list of filters. A media has one job at a time: delete the job before the media gets another one.

A job's status
statusMeaningWhat you do
queuedWaiting for its turn (queue.position, queue.message).Poll.
runningAt work in a stage: reconstructing, colorizing_keyframes, propagating (carrying the keyframes' colours to every frame), assembling, restoring, interpolating, super_resolving (scaling up), restoring_audio, finishing, uploading; with progress.percent (the whole job), stage_percent and eta_seconds when known.Poll.
needs_actionneeds_action keyframes (stage awaiting_keyframes): the colorized keyframes wait for your review. The job waits as long as it takes.Review and approve (step 7).
completedThe result is ready (result).Download (step 8).
failedThe job could not be finished (error.message); its charge is given back.Delete it; start again if you like.
cancelledStopped by a cancel; its charge is given back.Delete it before the media gets another job.

Cancel or delete? POST /jobs/{job_id}/cancel stops a job and keeps it (and the media) for the record; DELETE /jobs/{job_id} stops it if it runs and removes it with its result. Either way, what it was charged without delivering comes back exactly once.

Output options ("match source")

For a result that drops back onto an editing timeline, POST /jobs takes output:

OptionValuesEffect
geometryrestored (default), match_sourcematch_source: the result has the uploaded file's frame size, pixel aspect and position - the working size is scaled back and black borders are put back where they were. Needs output scaling source, and a picture that is not stretched or squeezed.
frame_rateprocessed (default), sourcesource: the uploaded file's frame rate. Not with natural speed or high frame rate, and after a pulldown removal only for media uploaded with ingest.keep_frame_rate.
audiosource (default), restored, nonesource keeps the sound as it is (the sound filters of the settings are left out); restored runs the sound filters of the settings; none leaves the sound out (editors keep sound on their own tracks).
timecodesourceThe uploaded file's timecode is carried into the result.
reel_name1 to 32 printable ASCII charactersWritten as the reel name of the result's timecode track; a file without timecode gets none.

Keyframes (colorization)

A colorization job first colorizes a few keyframes of every scene and then waits (needs_action keyframes). Look at them (GET /jobs/{job_id}/keyframes and the images; original_url is the same frame before colorization), change the colour strength (saturation, 50 to 200 percent, for the job and per keyframe) or leave keyframes out (PUT; every scene keeps at least one), then approve (POST .../keyframes/approve): the colours are carried to every frame. With "keyframes": "auto" on POST /jobs they are approved as they come.

A keyframe whose colours are not right can be changed with a sentence (POST .../keyframes/{frame}/modify, for example make all uniforms dark green): 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); every picture a change gives is a try, deleting one makes room); on the Prepaid plan each try is a small charge from the balance; on the Free and Invoice plans tries are not charged. Every result is kept as a version, and you choose which version is used (PUT, version; free, also when a keyframe keeps its most tries). Offered where modify.available is true. Approving waits for a change that is still running and carries the active version of every keyframe to every frame.

Billing

  • The price is exactly the processed seconds times the per-minute price of the job's tier, counted from zero and rounded to the nearest 0.10 - there is no minimum price. One price for every filter and service (restoration, colorization, sound, interpolation, Reconstruct, any combination): €25 per minute up to HD, €50 per minute for a 2K picture, and €50 per minute for a job that delivers 4K, whatever its source. Example: a 42-second HD job costs 42 ÷ 60 × 25 = €17.50. (per_minute of GET /me keeps its restoration and colorization tables, with the same prices in both unless your company has its own price list.)
  • The tier, exactly. Take the picture's long side (the larger of width and height) and its short side (the smaller). It is HD when the long side is at most 1920 and the short side at most 1080 (SD - at most 1280 and 720 - costs the same as HD); 2K when it is not HD and the long side is at most 2048 and the short side at most 1556; anything larger is not taken (next point). Then the result: output scaling 2160 makes the job 4K, output scaling 1080 makes it at least HD, and the job's tier is the higher of the picture's and the result's. So a portrait clip is priced as the same clip held landscape, a 1920×1200 picture is 2K (its short side is over 1080), and a 2K film scan stays 2K with output scaling 1080. Our list prices are in GET /health (pricing); your company's prices, with its discount, in GET /me (prices); POST /quotes gives the price of a job before it starts - and its tier.
  • The largest picture we take is 2048×1556 (either way round; limits.picture of GET /health). Larger files - a 4K scan, for example - are refused with PictureTooLarge: at POST /uploads when you send picture, at the upload's complete (we read the file's header), or when the media is analysed. 4K exists only as an upscaled result (output scaling 2160). The Free plan has no picture limit (limits.picture of GET /me is null there).
  • Taken at the start: POST /jobs takes the price from the prepaid balance (or the seconds from a Trial) at once and refuses a job that cannot be paid (BalanceInsufficient, SecondsLeftInsufficient). A job's billing.state is reserved, then settled when it delivers.
  • Given back once: a job that fails, is cancelled or is deleted before it delivered is refunded - exactly once, whatever happens after.
  • Other plans: Invoice - the job is invoiced monthly (billing invoice). The Free plan is free of charge (billing none, every price 0) and has no limits of its own: no quota, no watermark, no picture limit (limits.picture of GET /me is null) and no daily count of previews. Previews are never charged. The tries of Keyframe Modify (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.
  • Top-ups, orders and invoices stay on the website (links of GET /me).
The tier rule in examples (list prices)
Picture (width x height)Output scalingTierPer minuteA 42-second job
1920 × 1080 (HD video)sourceHD (hd)€25.00€17.50
1440 × 1080 (HDV, a 4:3 HD scan)sourceHD (hd)€25.00€17.50
720 × 576 (PAL SD video)sourceSD (sd, priced as HD)€25.00€17.50
1080 × 1920 (a portrait phone clip)sourceHD (hd)€25.00€17.50
1920 × 1200 (WUXGA)source2K (2k)€50.00€35.00
1998 × 1080 (DCI 2K flat)source2K (2k)€50.00€35.00
2048 × 858 (a 2K scope scan)source2K (2k)€50.00€35.00
2048 × 1556 (a 2K full-aperture film scan)source2K (2k)€50.00€35.00
2048 × 1556 (the same scan, delivered as HD)10802K (2k)€50.00€35.00
1440 × 1080 (an HD scan, delivered as 4K)21604K result (4k)€50.00€35.00
720 × 576 (PAL SD, delivered as HD)1080HD (hd)€25.00€17.50
3840 × 2160 (UHD video)sourcenot taken: PictureTooLarge--
4096 × 3112 (a 4K film scan)sourcenot taken: PictureTooLarge--

The Trial for app sign-ups

An account made in one of our apps (POST /auth/signup) starts on a Trial with 15 seconds of restoration and 15 seconds of colorization. Trial results carry a visible and audible watermark - on the picture, and a short chime in the sound every 15 seconds (watermark true in the quote and the job); sound-only results carry the audible one. These seconds are what limits a Trial job: a job longer than the seconds left answers SecondsLeftInsufficient. (The Trial's own length limit per media - 300 seconds for restoration and 60 for colorization, TrialLimitExceeded, limits.trial_max_seconds of GET /me - matters only on an account we have given more Trial seconds than that.) To leave the Trial, upgrade to Prepaid in the workspace (links.upgrade of GET /me), then top up: Prepaid results have no watermark. A top-up alone does not change the plan. Accounts made on the website start on Prepaid.

Cloud servers

Some companies' media live on their own cloud server. You do not need to know: always send your requests to the base address, which passes them on. A stopped cloud server is started when a request needs it (CloudServerStarting: try again after Retry-After). With an API key you can send chunks straight to the server that holds the upload (direct_chunk_url_template).

Conventions

Rules that hold for every endpoint. The examples on this page use these shell variables (step 0 of the workflow sets the first two): API the base address; AUTH your API key and secret, which curl -u sends as HTTP Basic; TOKEN for apps, an access token, sent as -H "Authorization: Bearer $TOKEN":

Shell: the variables of the examples
API=https://www.recuromedia.com/api/1.0
AUTH="YOUR_API_KEY:YOUR_API_SECRET"
TOKEN=rma_4QyH0m1Zr8VdP2cX7kLw9sNb3TgE6uJaF5oYiR1tK0e

Headers

Headers you send
HeaderWhenWhat it is
Authorizationevery request, except the few marked "none"Basic with your key and secret, or Bearer with an access token (Getting access).
Content-Typewith a bodyapplication/json; for a chunk application/octet-stream.
Recuro-Clientrecommended on every requestYour program and its version: <name>/<version> (<host app>; <operating system>), for example my-mam-connector/2.3 (Linux). Our own apps below the lowest accepted version get ClientVersionUnsupported.
Idempotency-Keyrequests that create somethingSee Idempotency keys.
If-None-Matchwhen you pollThe last ETag; see Polling.
Range, If-Rangedownloading a resultResume a download (GET /jobs/{job_id}/result).
Repr-Digestsending a chunkThe chunk's SHA-256 (chunks).
Headers you get
HeaderWhenWhat it is
Recuro-Request-Idevery responseThe request's id, for example req_6c1f0b9e2d4a. Quote it when you ask us about a request.
ETagresponses you can poll, filesA fingerprint of the response: weak (W/ in front) for JSON responses, the same on the 200 and its 304; strong for files. Send it back exactly as it came in If-None-Match (compared weakly: with or without W/, several separated by commas, or *).
Retry-Afterresponses you poll, 429 and some errorsSeconds to wait before you ask again.
Location201 and most 202 responses (not a cancel or an approval)The address of what was made.
Idempotent-Replaya repeated requesttrue: this is the stored first response of an Idempotency-Key.
RateLimit-Limit, RateLimit-Remaining, RateLimit-Resetendpoints with a per-minute limitOf the tightest limit that applies to the request - your credential's or your user's: that limit, how many requests are left, and in how many seconds the count starts again (Limits). A 304 does not count.
Allow405 responsesThe HTTP methods the address takes.

The API answers the CORS preflight requests of browsers and web views (OPTIONS) on every address.

Times, money and ids

  • Times are ISO 8601 in UTC: 2026-10-06T09:04:18Z (the Z means UTC). Durations are in seconds, frames are counted from 0, frame rates are a fraction: {"num": 24000, "den": 1001} is 23.976 frames per second. Sizes are in bytes.
  • Money is a decimal number written as a string, with its currency: {"amount": "12.40", "currency": "EUR"}. (A string, so that no program rounds it.)
  • Ids carry a prefix that tells what they are: med_ media, job_ jobs, prv_ previews, upl_ uploads, pre_ presets, al_ sign-ins, usr_ users, cmp_ companies. Store them as they are; do not take them apart.
  • null means "no value" (for example "result": null before a job has completed). A field the reference marks "only when ..." is missing from the JSON otherwise.

Lists and pages

GET /media and GET /jobs answer newest first, in pages of limit entries (1 to 200, default 50): {"data": [...], "next_cursor": "eyJiZWZvcmUiOjQ3OTB9"}. For the next page send the next_cursor as cursor: GET /jobs?cursor=eyJiZWZvcmUiOjQ3OTB9. On the last page next_cursor is null. The cursor is an opaque string: send it back unchanged.

Polling, ETags and Retry-After

Many things take time: an analysis, a preview, a job, a keyframe change. You learn that they have finished by polling - asking again and again - or, with an API key, by webhooks. Poll like this:

  1. Ask (GET /jobs/{job_id}, GET /media/{media_id}, GET /previews/{preview_id}, GET .../keyframes/{frame}/modify). Keep the response's ETag header. (GET /me, GET /catalogue and GET /uploads/{upload_id} answer an ETag too.)
  2. Wait the number of seconds of the Retry-After header: 1 while a job is queued or running, a media analysed, a preview made or a keyframe changed; 60 otherwise. Never poll the same thing faster than once a second. Waiting longer (for example 5 seconds in a script) is always fine. An upload answers no Retry-After: it changes only when you send chunks, so there is nothing to wait for - ask it when you need its missing list, at most once a second.
  3. Ask again with If-None-Match: <the ETag>. When nothing has changed the response is 304 Not Modified without a body - and a 304 never counts against your limits, whatever you ask (also GET /me and GET /catalogue). When something has changed you get 200 with the new state and a new ETag; it counts as a normal request.
Shell
curl -sS -i -u "$AUTH" "$API/jobs/job_3150" -H 'If-None-Match: W/"4fba7bfc885cb47a48c0a537fd8ba09e"'
Response
HTTP/2 304
etag: W/"4fba7bfc885cb47a48c0a537fd8ba09e"
retry-after: 1
ratelimit-limit: 120
ratelimit-remaining: 118
ratelimit-reset: 41

Idempotency keys

A network can lose a response: your program sent POST /jobs, the job was started, but the answer never arrived. If you simply send the request again, you start - and pay for - a second job. An idempotency key prevents that:

  1. Before a request that creates something - POST /uploads, POST /media/imports, POST /media/{media_id}/previews, POST /jobs and POST /jobs/{job_id}/keyframes/{frame}/modify - make a new random value (a UUID) and send it as the Idempotency-Key header (1 to 64 printable characters).
  2. If no response arrives, send the same request with the same key again. Within 24 hours you get the first response again (with the header Idempotent-Replay: true) - the same job, nothing made or charged twice.
  3. For the next, different request, make a new key. The same key with a different body is refused (IdempotencyKeyReused).

In the shell examples a line like JOB_KEY=${JOB_KEY:-$(openssl rand -hex 16)} makes the key: it makes a new one only while the variable is empty, so pasting the same block again sends the same key. Step 0 of the workflow empties the keys for the next file (unset UPLOAD_KEY JOB_KEY). The reference's examples keep their own variables (KEY_UPLOADS, KEY_JOBS ...), so they never send a key of the workflow; before you run one again for another request, empty its variable the same way (unset KEY_UPLOADS).

Only successful responses are stored: after a refusal (for example BalanceInsufficient) you can send the same request with the same key again once the cause is gone. Requests of one user that carry an Idempotency-Key are handled one after the other: a second one waits until the first has finished, at most 30 seconds - then it answers RateLimited with Retry-After: 2.

Python: making a key
import uuid
key = str(uuid.uuid4())          # one key for this one job; store it with your record of the job before you send
# send POST /jobs with the header {"Idempotency-Key": key}; no response? send exactly the same request with the same key again

Limits

Limits protect the service for everyone. Every response of an endpoint with a per-minute limit carries RateLimit-Limit (your limit), RateLimit-Remaining (requests left) and RateLimit-Reset (seconds until the count starts again) of the tightest limit that applies to the request: your credential's, or your user's (all your credentials together) when that one has fewer requests left - so RateLimit-Remaining counts down to the limit that refuses first. When you go over a limit the response is 429 (RateLimited or the code of the limit) with a Retry-After header: wait that many seconds, then send the same request again. There is no penalty and no data is lost - except for wrong API keys and passwords: those block your internet address for a while (the table below). A 304 Not Modified never counts against a limit (also of GET /me and GET /catalogue). GET /health, GET /openapi.yaml, chunks and result downloads have no per-minute limit.

Requests
WhatLimitCounted per
Requests in general120 a minute; a 304 is freecredential (API key or sign-in); also 480 a minute per user for all credentials together
Polling one media, upload, preview, job or keyframe change120 a minute; a 304 is freecredential and thing polled; also 240 a minute per user and thing
Chunks4 at the same time per upload, 16 per credential, 32 per user; no count per minuteupload / credential / user
Sign-in endpoints (/auth/... except /auth/logout, which counts as a request in general; the browser sign-in's token poll has its own limit, below)30 a minute, plus the website's limits on failed sign-insinternet address
Wrong API key or secret20 in 15 minutes; then the address is blocked until the oldest failure is 15 minutes oldinternet address
Refreshing a sign-in30 an hoursign-in
Browser sign-ins started10 in 10 minutes; each polled every 5 seconds at mostinternet address / sign-in
Browser sign-in token polls (POST /auth/app-logins/{login_id}/token)60 a minuteinternet address
Free previews20 restoration and 5 colorization in 24 hours (no count on the Free plan), 2 being made at the same timecompany / user
Keyframe changes (Modify)Free, Prepaid and Invoice 5 tries per keyframe at a time, Trial none (Modify is not included) - deleting one makes room (every picture a change gives is a try, also one our check rejected; a change for the whole scene is a try on each of its keyframes); on the Prepaid plan each try is a small charge from the balance; on the Free and Invoice plans tries are not charged; one running per jobplan: keyframe / job
Sizes and times
WhatLimit
A picture2048 × 1556 pixels at most, either way round (2K; limits.picture of GET /health) - PictureTooLarge above it; 4K only as an upscaled result
A file50 GiB (53,687,091,200 bytes); .mov, .mp4, .mxf, .mkv, .mpg, .mpeg, .wav, .mp3
Open uploads10 per user, each kept 7 days after its last chunk
A chunk4 to 32 MiB (default 8 MiB)
A JSON body1 MiB (1,048,576 bytes); client_data and source 4 KB (4,096 bytes) of JSON each
A keyframe change's text1 to 300 characters on one line
Preview uploads, previewsdeleted 24 hours after the upload; 6 hours after the preview was made
Results60 days after the job finished (result.expires_at)
Download links60 seconds to 24 hours
Idempotency keysthe first response is kept 24 hours
Access tokens, refresh tokens60 minutes; 90 days, starting again with every refresh