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 stillmissing.
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:
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).
| Key | Name | What it does |
|---|---|---|
noise | Noise | Removes noise that changes from frame to frame and makes the picture smoother. |
re_grain | Re-grain | Adds film grain back after the restoration, for the look of film. |
light_dust_and_scratches | Light dust & scratches | Removes dust; suits fast-moving scenes such as sports. |
medium_dust_and_scratches | Medium dust & scratches | Removes most dust; suits most material. |
heavy_dust_and_scratches | Heavy dust & scratches | Removes almost all dust and mould, for badly damaged film. |
color_balance | Color balance | Evens the white balance and removes discolouration of aged film. |
color_saturation | Color saturation | Restores the colour saturation of scenes with too little or too much. |
contrast_expansion | Contrast expansion | Increases the contrast, which brings out detail. |
colorize | Colorize | Colorizes black-and-white film; the job then waits for your keyframe review (Keyframes). |
image_stabilization | Image stabilization | Removes fast shaking (frames jumping up and down). |
reduce_flicker | Reduce flicker | Reduces the flickering of light between frames of old black-and-white film. |
content_stabilization | Content stabilization | Reduces the shakiness of an unsteady camera. |
adaptive_sharpness | Adaptive sharpness | Sharpens detail as much as each scene needs. |
color_casting | Color casting | Clears a strong colour haze over the whole film. |
bad_scene_changes | Bad scene changes | Replaces up to two bad frames at a cut between scenes. |
reconstruct | Reconstruct | Reconstructs washed-out or blurry film before it is restored. |
repair_deinterlace | Repair deinterlace | Repairs progressive film that was badly deinterlaced. |
high_frame_rate | High frame rate | Doubles the frame rate for smoother motion. Changes the timing: not with output.frame_rate source. |
vertical_scratches | Vertical scratches | Reduces long vertical scratches that run through many frames. |
natural_speed | Natural speed | Makes 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_volume | Normalize volume | Sound: raises the volume to a normal level without distortion. |
reduce_noise | Reduce noise | Sound: reduces background noise. |
increase_clarity | Increase clarity | Sound: 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.
| status | Meaning | What you do |
|---|---|---|
queued | Waiting for its turn (queue.position, queue.message). | Poll. |
running | At 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_action | needs_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). |
completed | The result is ready (result). | Download (step 8). |
failed | The job could not be finished (error.message); its charge is given back. | Delete it; start again if you like. |
cancelled | Stopped 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:
| Option | Values | Effect |
|---|---|---|
geometry | restored (default), match_source | match_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_rate | processed (default), source | source: 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. |
audio | source (default), restored, none | source 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). |
timecode | source | The uploaded file's timecode is carried into the result. |
reel_name | 1 to 32 printable ASCII characters | Written 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_minuteofGET /mekeeps itsrestorationandcolorizationtables, 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
2160makes the job 4K, output scaling1080makes 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 scaling1080. Our list prices are inGET /health(pricing); your company's prices, with its discount, inGET /me(prices);POST /quotesgives the price of a job before it starts - and itstier. - The largest picture we take is 2048×1556
(either way round;
limits.pictureofGET /health). Larger files - a 4K scan, for example - are refused withPictureTooLarge: atPOST /uploadswhen you sendpicture, at the upload'scomplete(we read the file's header), or when the media is analysed. 4K exists only as an upscaled result (output scaling2160). The Free plan has no picture limit (limits.pictureofGET /meisnullthere). - Taken at the start:
POST /jobstakes 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'sbilling.stateisreserved, thensettledwhen 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 (
billinginvoice). The Free plan is free of charge (billingnone, every price 0) and has no limits of its own: no quota, no watermark, no picture limit (limits.pictureofGET /meisnull) 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 (
linksofGET /me).
| Picture (width x height) | Output scaling | Tier | Per minute | A 42-second job |
|---|---|---|---|---|
| 1920 × 1080 (HD video) | source | HD (hd) | €25.00 | €17.50 |
| 1440 × 1080 (HDV, a 4:3 HD scan) | source | HD (hd) | €25.00 | €17.50 |
| 720 × 576 (PAL SD video) | source | SD (sd, priced as HD) | €25.00 | €17.50 |
| 1080 × 1920 (a portrait phone clip) | source | HD (hd) | €25.00 | €17.50 |
| 1920 × 1200 (WUXGA) | source | 2K (2k) | €50.00 | €35.00 |
| 1998 × 1080 (DCI 2K flat) | source | 2K (2k) | €50.00 | €35.00 |
| 2048 × 858 (a 2K scope scan) | source | 2K (2k) | €50.00 | €35.00 |
| 2048 × 1556 (a 2K full-aperture film scan) | source | 2K (2k) | €50.00 | €35.00 |
| 2048 × 1556 (the same scan, delivered as HD) | 1080 | 2K (2k) | €50.00 | €35.00 |
| 1440 × 1080 (an HD scan, delivered as 4K) | 2160 | 4K result (4k) | €50.00 | €35.00 |
| 720 × 576 (PAL SD, delivered as HD) | 1080 | HD (hd) | €25.00 | €17.50 |
| 3840 × 2160 (UHD video) | source | not taken: PictureTooLarge | - | - |
| 4096 × 3112 (a 4K film scan) | source | not 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":
API=https://www.recuromedia.com/api/1.0
AUTH="YOUR_API_KEY:YOUR_API_SECRET"
TOKEN=rma_4QyH0m1Zr8VdP2cX7kLw9sNb3TgE6uJaF5oYiR1tK0eHeaders
| Header | When | What it is |
|---|---|---|
Authorization | every request, except the few marked "none" | Basic with your key and secret, or Bearer with an access token (Getting access). |
Content-Type | with a body | application/json; for a chunk application/octet-stream. |
Recuro-Client | recommended on every request | Your 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-Key | requests that create something | See Idempotency keys. |
If-None-Match | when you poll | The last ETag; see Polling. |
Range, If-Range | downloading a result | Resume a download (GET /jobs/{job_id}/result). |
Repr-Digest | sending a chunk | The chunk's SHA-256 (chunks). |
| Header | When | What it is |
|---|---|---|
Recuro-Request-Id | every response | The request's id, for example req_6c1f0b9e2d4a. Quote it when you ask us about a request. |
ETag | responses you can poll, files | A 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-After | responses you poll, 429 and some errors | Seconds to wait before you ask again. |
Location | 201 and most 202 responses (not a cancel or an approval) | The address of what was made. |
Idempotent-Replay | a repeated request | true: this is the stored first response of an Idempotency-Key. |
RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset | endpoints with a per-minute limit | Of 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. |
Allow | 405 responses | The 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(theZmeans 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. nullmeans "no value" (for example"result": nullbefore 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:
- Ask (
GET /jobs/{job_id},GET /media/{media_id},GET /previews/{preview_id},GET .../keyframes/{frame}/modify). Keep the response'sETagheader. (GET /me,GET /catalogueandGET /uploads/{upload_id}answer anETagtoo.) - Wait the number of seconds of the
Retry-Afterheader: 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 noRetry-After: it changes only when you send chunks, so there is nothing to wait for - ask it when you need itsmissinglist, at most once a second. - Ask again with
If-None-Match: <the ETag>. When nothing has changed the response is304 Not Modifiedwithout a body - and a 304 never counts against your limits, whatever you ask (alsoGET /meandGET /catalogue). When something has changed you get200with the new state and a newETag; it counts as a normal request.
curl -sS -i -u "$AUTH" "$API/jobs/job_3150" -H 'If-None-Match: W/"4fba7bfc885cb47a48c0a537fd8ba09e"'HTTP/2 304
etag: W/"4fba7bfc885cb47a48c0a537fd8ba09e"
retry-after: 1
ratelimit-limit: 120
ratelimit-remaining: 118
ratelimit-reset: 41Idempotency 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:
- Before a request that creates something -
POST /uploads,POST /media/imports,POST /media/{media_id}/previews,POST /jobsandPOST /jobs/{job_id}/keyframes/{frame}/modify- make a new random value (a UUID) and send it as theIdempotency-Keyheader (1 to 64 printable characters). - 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. - 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.
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 againLimits
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.
| What | Limit | Counted per |
|---|---|---|
| Requests in general | 120 a minute; a 304 is free | credential (API key or sign-in); also 480 a minute per user for all credentials together |
| Polling one media, upload, preview, job or keyframe change | 120 a minute; a 304 is free | credential and thing polled; also 240 a minute per user and thing |
| Chunks | 4 at the same time per upload, 16 per credential, 32 per user; no count per minute | upload / 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-ins | internet address |
| Wrong API key or secret | 20 in 15 minutes; then the address is blocked until the oldest failure is 15 minutes old | internet address |
| Refreshing a sign-in | 30 an hour | sign-in |
| Browser sign-ins started | 10 in 10 minutes; each polled every 5 seconds at most | internet address / sign-in |
Browser sign-in token polls (POST /auth/app-logins/{login_id}/token) | 60 a minute | internet address |
| Free previews | 20 restoration and 5 colorization in 24 hours (no count on the Free plan), 2 being made at the same time | company / 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 job | plan: keyframe / job |
| What | Limit |
|---|---|
| A picture | 2048 × 1556 pixels at most, either way round (2K; limits.picture of GET /health) - PictureTooLarge above it; 4K only as an upscaled result |
| A file | 50 GiB (53,687,091,200 bytes); .mov, .mp4, .mxf, .mkv, .mpg, .mpeg, .wav, .mp3 |
| Open uploads | 10 per user, each kept 7 days after its last chunk |
| A chunk | 4 to 32 MiB (default 8 MiB) |
| A JSON body | 1 MiB (1,048,576 bytes); client_data and source 4 KB (4,096 bytes) of JSON each |
A keyframe change's text | 1 to 300 characters on one line |
| Preview uploads, previews | deleted 24 hours after the upload; 6 hours after the preview was made |
| Results | 60 days after the job finished (result.expires_at) |
| Download links | 60 seconds to 24 hours |
| Idempotency keys | the first response is kept 24 hours |
| Access tokens, refresh tokens | 60 minutes; 90 days, starting again with every refresh |
