Fit a recording under the 25 MB transcription limit in n8n
OpenAI's transcription API (Whisper and gpt-transcribe) takes files up to 25 MB. How long a recording fits, the free ffmpeg route and where it stops on n8n Cloud, and a compress-to-size API for when you cannot run ffmpeg.
Commands, requests and scripts tested on 26 October 2026.
A 20-minute screen recording is easily 200 MB. Send it to OpenAI's transcription endpoint and it is rejected, because the API takes files of at most 25 MB. On a machine with ffmpeg this takes one command to fix. In n8n it depends on where n8n runs, and on n8n Cloud the usual fix is not available at all.
This guide gives the arithmetic first, then the free ffmpeg route, then an API route for n8n Cloud.
Disclosure: Quarrywise, the publisher of this site, built and sells the Apify Actor used in option 2. Option 1 needs nothing from us.
The 25 MB limit, and why zipping does not help
OpenAI's speech-to-text guide says "Files can be up to 25 MB" and accepts mp3, mp4,
mpeg, mpga, m4a, wav and webm. n8n's OpenAI node documents the same
limit. The guide names gpt-transcribe as the recommended model, lists whisper-1 and the
gpt-4o transcribe models as deprecated, and says they "will shut down on February 26, 2027".
It gives no different size limit for gpt-transcribe, so plan for 25 MB either way.
Zipping or gzipping the file does not help. Video and audio are already compressed, so a zip barely shrinks them, and the API wants the media file itself, not an archive. To get under the limit you have to send less data: audio only, a lower bitrate, or shorter pieces.
How long a recording fits in 25 MB
File size is bitrate times duration. 25 MB is 25,000,000 bytes, or 200,000,000 bits:
| What you send | Fits in 25 MB |
|---|---|
| Audio only, 64 kb/s | about 52 minutes |
| Audio only, 48 kb/s | about 69 minutes |
| Audio only, 32 kb/s mono | about 1 hour 44 minutes |
| Video compressed to 24 MB with our Actor, default audio (16:9, 30 fps) | about 19 minutes, at 240p |
The same with "audioBitrateKbps": 32 |
about 23 minutes |
The same with "audioBitrateKbps": 32 and "maxFrameRate": "24" |
about 27 minutes |
The audio rows are plain arithmetic, and our 32 kb/s test file below came out close to it. The video rows come from running the Actor's own planning code for a 1080p, 30 fps recording with a 24 MB target; they are computed, not yet confirmed with real long recordings. Longer videos are refused, free, with the smallest size that would work.
The conclusion is simple: for transcription, audio only is the better route whenever you can run ffmpeg. At 32 kb/s it fits about 1 hour 44 minutes of speech against about 23 minutes of video, and speech recognition does not need the picture. Compressing the video is for when you cannot run ffmpeg, or when you also want to keep a small watchable copy.
Option 1: extract the audio with ffmpeg (free)
One command turns a video into a small mono MP3. We ran it with ffmpeg 8.1:
ffmpeg -i recording.mp4 -vn -ac 1 -ar 16000 -c:a libmp3lame -b:a 32k recording.mp3
-vndrops the video.-ac 1mixes down to mono; speech does not need stereo.-ar 16000sets a 16 kHz sample rate, plenty for speech.-b:a 32ksets 32 kb/s. Use48kor64kif speakers are hard to understand.
Our 12-second, 6.2 MB test video became a 48,712-byte MP3: about 4 KB per second, close to the arithmetic, so plan on about 1 hour 44 minutes in 25 MB (a little less with the file's own overhead). For anything longer, split the audio into pieces and transcribe them one by one:
ffmpeg -i recording.mp3 -f segment -segment_time 3000 -c copy part_%03d.mp3
That cuts 50-minute pieces (about 12 MB each at 32 kb/s) without re-encoding. We tested it with 5-second pieces on the same file, which gave three parts. Pieces are cut at a time mark, not at a pause, so a word can be split across two pieces.
Running ffmpeg inside n8n
This is where it depends on how you run n8n:
- n8n Cloud: the node that runs shell commands, Execute Command, "isn't available on n8n Cloud" (n8n docs). A Cloud workflow cannot call ffmpeg.
- Self-hosted n8n: Execute Command is disabled by default from n8n 2.0. Re-enable it by
removing it from the
NODES_EXCLUDElist (n8n's docs showNODES_EXCLUDE: "[]"). The official Docker image does not include ffmpeg, so you also need a custom image built on top of it; n8n's docs explain that the image no longer ships theapkpackage manager, which has to be restored first. - Self-hosted, without a shell: the free, MIT-licensed community node
n8n-nodes-ffmpeg-wasmruns ffmpeg in WebAssembly and has a "Compress to Size" operation. Its README describes installing it in n8n's custom nodes folder, so it is for self-hosted instances.
If you self-host and can do one of these, do it. The audio route costs nothing per file.
Option 2: compress to size with an API (n8n Cloud)
On n8n Cloud the work has to happen somewhere else, through an HTTP call. General file-conversion services such as CloudConvert, or FFHub (a hosted ffmpeg service with its own n8n community node), can do it if you work out the settings yourself.
We built Compress Video to Size: Under 10 MB, 25 MB or Any for exactly this kind of step. What it does differently from a plain ffmpeg API:
- You give it a size, not encoder settings. It works out the bitrate from the video's length, encodes in two passes and picks the sharpest resolution the budget can carry.
- It checks the finished file and redoes it smaller if it came out even one byte over, so
output_bytesis never above the target. Awithin_limitflag tells your workflow whether to continue. - If the target is too small for a watchable picture, it does not encode, charges nothing, and says the smallest size that would work.
It works on video files you send it: direct or signed links (for example from S3, R2 or Dropbox), uploads, or records in an Apify key-value store. Pages on YouTube, TikTok and other video platforms are refused.
Tested script: upload, compress, read the result
This Python script (standard library only) handles a local file: it puts the file in a named
Apify key-value store, starts the Actor without waiting, waits for the run, reads the result and
then deletes the uploaded original. The delete sits in a finally block, so it also runs
when the run fails, returns no row or the script is stopped. It reads your Apify API token from the
APIFY_TOKEN environment variable.
import json, os, pathlib, sys, urllib.request
TOKEN = os.environ["APIFY_TOKEN"] # your Apify API token; never paste it into the code
API = "https://api.apify.com/v2"
ACTOR = "quarrywise~video-compress-to-size"
TARGET_MB = float(sys.argv[2]) if len(sys.argv) > 2 else 24 # leave headroom under 25 MB
def send(method, path, body=None, content_type="application/json"):
req = urllib.request.Request(API + path, data=body, method=method, headers={
"Authorization": f"Bearer {TOKEN}", "Content-Type": content_type})
with urllib.request.urlopen(req, timeout=600) as resp:
return resp.read()
def call(method, path, body=None):
return json.loads(send(method, path, body))
video = pathlib.Path(sys.argv[1])
# 1. Put the file in a named key-value store (created on first use, reused after).
store = call("POST", "/key-value-stores?name=video-inbox")["data"]["id"]
key = "in-" + "".join(c if c.isalnum() or c in "-_." else "-" for c in video.name)
record = f"/key-value-stores/{store}/records/{key}"
send("PUT", record, video.read_bytes(), "video/mp4") # the answer has no body
try:
# 2. Start the Actor without waiting for it (long videos take minutes).
# Add "audioBitrateKbps": 32 to fit longer recordings; small files are then re-encoded too.
run_input = {"keyValueStoreId": store, "fileKeys": [key], "targetSizeMb": TARGET_MB}
run = call("POST", f"/acts/{ACTOR}/runs?maxTotalChargeUsd=5", json.dumps(run_input).encode())["data"]
# 3. Wait for the run to finish (each call waits up to 60 s on the server).
while run["status"] in ("READY", "RUNNING", "TIMING-OUT", "ABORTING"):
run = call("GET", f"/actor-runs/{run['id']}?waitForFinish=60")["data"]
print("run", run["status"])
# 4. Read the result row and branch on within_limit.
rows = call("GET", f"/datasets/{run['defaultDatasetId']}/items?clean=true")
if not rows:
print("no result row; see the run's log in Apify Console")
elif rows[0]["within_limit"]:
row = rows[0]
print(row["status"], row["output_bytes"], "bytes,", row["resolution"], row["output_url"])
else:
print("not compressed:", rows[0]["status"], rows[0].get("note") or rows[0].get("error_message"))
finally:
# 5. Always delete the uploaded original. Named stores do not expire, so it would stay there.
send("DELETE", record)
print("deleted", key)
We ran it on a 2-second, 31 KB test clip (H.264 with AAC audio) with the default 24 MB target. The clip was already under the target, so the Actor copied it into MP4 without re-encoding, for the $0.01 per-video price only. It printed:
run SUCCEEDED
copied 30829 bytes, 320x240 https://api.apify.com/v2/key-value-stores/<store id>/records/fit-001-in-guide-test-tiny.mp4?signature=<signature>
deleted in-guide-test-tiny.mp4
An earlier version of the script, run on our 12-second 1080p test video with a 2 MB target,
printed a compressed row of 1,972,039 bytes at 1280x720.
Step 5 matters. Named stores do not expire; the uploaded original stays there until you
delete it. The script deletes it with
DELETE https://api.apify.com/v2/key-value-stores/{storeId}/records/{key} in a
finally block. After our run that record answered 404. We also ran the script with the
call that starts the run made to fail on purpose, right after the upload: the script stopped with
the error, the delete still ran, and the record answered 404.
The compressed file is not in the named store: it is in the run's own key-value store, which
Apify deletes after your plan's data retention period. The output_url is a signed link
on api.apify.com, so it needs no token. A 4.9 MB output and a 21.4 MB output each
answered 200 without a redirect when we downloaded them. Apify's API reference also lists a possible
302 redirect for record downloads, without naming where it points, so the n8n workflow below
handles one. Anyone with the link can download the file until the run's storage expires, so do not
post it publicly.
If the video already sits behind a direct link, skip the upload and pass the link instead. We ran this with our public test file:
curl -s -X POST "https://api.apify.com/v2/acts/quarrywise~video-compress-to-size/runs?waitForFinish=120&maxTotalChargeUsd=1" \
-H "Authorization: Bearer $APIFY_TOKEN" -H "Content-Type: application/json" \
-d '{"videoUrls": ["https://quarrywise.com/samples/fit-to-size-test-1080p.mp4"], "targetSizeMb": 24, "audioBitrateKbps": 32}'
The run succeeded, and its dataset row taught us something worth passing on. The 6.2 MB file was
already under 24 MB, but because we asked for 32 kb/s audio it could not be copied as it was, so it
was re-encoded to a 7.96 MB file: still under the limit, but larger than the original, and charged
per second. Leave audioBitrateKbps out unless you need the extra minutes.
Without it, a file already under the target in a copyable format (H.264 or the codec you chose,
8-bit 4:2:0, standard dynamic range, square pixels, within the resolution and frame-rate caps of
1080p and 30 fps by default, AAC or MP3 audio or none) is copied into MP4 without re-encoding for
$0.01, as the script run above shows; other files, such as HEVC iPhone clips, VP9 WebM, 10-bit or
HDR video, are re-encoded and charged per second. With 32 kb/s audio, about 23 instead
of about 19 minutes fit in 24 MB, but small files are re-encoded too.
The workflow, node by node
Encoding a long video takes minutes. The Apify API's synchronous run-sync endpoint waits at most
300 seconds, but n8n's Apify node does not use it: its Run an Actor and Get Dataset
operation starts the run, polls it until the run ends and then reads the dataset. We checked this in
the source of @apify/n8n-nodes-apify 0.8.1 and 0.9.0 and in a real n8n run. So one
workflow is enough. Every video link goes to the Actor, which downloads it with its own checks
(private and local network addresses blocked, every redirect checked again, size caps); n8n itself
never fetches the video link. The workflow has 15 nodes:
- Webhook, "Receive video link" (a POST with
{"videoUrl": "https://..."}, protected by Header Auth), or a Manual Trigger plus a Set with our 12-second sample link while you build it. - Set, "Your settings":
targetSizeMb24,audioBitrateKbpsempty (or a whole number from 32 to 192; 32 fits about 23 instead of about 19 minutes, at the price of re-encoding small files; the Actor's 0 would remove the audio, so the workflow refuses it) andallowedHostSuffixes(empty means any https host). - Code, "Check the video link": stops the run unless the link is https, without a user name or password and, if you set allowed hosts, on one of them, then builds the Actor input. It checks the link's own host only: a guard against mistakes, not a network boundary.
- Apify, "Compress video on Apify", operation Run an Actor and Get
Dataset, Actor set By URL to
https://console.apify.com/actors/lCJp5enIHmRFUtepP/input(or the ID mode withlCJp5enIHmRFUtepP), with input{"videoUrls": ["..."], "targetSizeMb": 24, "maxVideos": 1}(plusaudioBitrateKbpsonly if you set it), 4096 MB of memory and a maximum cost per run. - IF, "Fits under the target?":
within_limitis true. - False branch: Set, "Why it did not fit", keeps the row's reason and
min_target_mb, the smallest size that would work. - Code, "Check the download link": accepts
output_urlonly as an https link onapi.apify.com, the host the Actor returns, with a path of exactly/v2/key-value-stores/<store>/records/<key>and no encoded slash or backslash, and refuses anything else. - HTTP Request, "Download compressed MP4": downloads
output_urlas a file, without following redirects, with Include Response Headers and Status and Never Error on. It is a signed link, so no token is needed. - Code, "Check the download response": a 200 passes the file on. Apify's API
reference lists a possible 302 for record downloads without naming the target host, so on a 302
the node accepts the
Locationonly as an https link onapi.apify.com; any other host or status stops the run. - IF, "Redirected?": if there is a checked redirect link, an HTTP Request, "Download from the redirect", fetches it, again without following redirects; otherwise the file goes straight on.
- OpenAI, "Transcribe with OpenAI", then a Set, "Transcript", that keeps the text and the video link. Add your destination after it.
Only the link passes through n8n, never the source video, so n8n's memory only has to hold the
compressed file of at most 24 MB. The sample is 6.2 MB, so with the default settings the Actor
copies it for $0.01; set targetSizeMb to 5 to watch a real compression.
An earlier version of this workflow file ran in a local n8n 2.44.7 with the live Apify API: on our
12-second sample the Apify node returned a compressed row and the HTTP Request downloaded
the MP4 without a token. The three Code nodes of the current version were tested outside n8n with
hostile links and responses; the redirect branch, the OpenAI step and n8n Cloud were not part of
any run. We will link the file here
once it is published.
The Webhook is protected with Header Auth: before you activate it, create a Header Auth credential in n8n with a header name and a long random value, and send that header with every POST. Keep the webhook path private as well, because every accepted call starts a paid Apify run on your account.
When the video is a file, not a link
If the recording arrives as a file, for example in a Google Drive folder, it has to be uploaded to Apify first, as in the Python script:
- Google Drive Trigger (a new file in a folder), then Google Drive, download the file.
- IF: if the file is already under 24 MB, send it straight to the OpenAI node.
- HTTP Request:
PUT https://api.apify.com/v2/key-value-stores/{storeId}/records/{key}with the file as the binary body. Use a stored Header Auth credential (Authorization: Bearerplus your token), never a token typed into the URL or the node. - Apify, Run an Actor and Get Dataset, with input
{"keyValueStoreId": "...", "fileKeys": ["..."], "targetSizeMb": 24}. In the node's settings, set On Error to continue, so that a failed or aborted run still passes an item on, and turn on Always Output Data, so that a run that ends with an empty dataset (for example one stopped at its maximum charge before any row) passes an empty item on too. Without both, the DELETE below can be skipped. - HTTP Request:
DELETE https://api.apify.com/v2/key-value-stores/{storeId}/records/{key}with the same credential. Connect the Apify node's output both to this node and to the IF below, so the DELETE runs whatever the result. Take the store ID and key from the step that set them before the PUT (for example a Set node, read with$('Set').item), not from the Apify node's output, which is empty or an error item after a failed run. Named stores do not expire; the uploaded original stays there until you delete it. - From the Apify node, the same IF, link check, download and OpenAI nodes as above. An error item
from a failed run, or the empty item of a run without a row, has no
within_limit, so it takes the IF's false branch.
Two workflows, for very long recordings or a webhook callback
A single execution stays open while the run encodes. For very long recordings, or if you prefer Apify to call n8n back when the run is done, split it in two:
- Workflow A hands the video over: the same steps up to the Apify node, with the operation Run an Actor, which starts the run and does not wait for it.
- Workflow B starts on the Apify Trigger, "On new Apify event", for the
Compress Video to Size Actor and the event Succeeded. It reads the run's default dataset with
Get dataset items, then runs the IF, link check, download and OpenAI nodes. If
workflow A uploaded a file, workflow B also deletes the input record; also listen for the failed,
aborted and timed-out events so the record is deleted after a failed run too. Workflow B learns
which record to delete from the run's own
INPUTrecord: an HTTP Request GET tohttps://api.apify.com/v2/key-value-stores/{defaultKeyValueStoreId}/records/INPUT(the run's default store, from the trigger's run data, with the same stored credential) returns thekeyValueStoreIdandfileKeysthat workflow A sent. Turn on Always Output Data on the dataset step as well, so an empty dataset does not stop workflow B before the DELETE.
Notes before you build it:
- Files already under 24 MB: leave
audioBitrateKbpsempty. A file already under the target in a copyable format (for example H.264 with AAC audio, up to 1080p and 30 fps) is copied without re-encoding for $0.01, as the script run showed; other files are re-encoded and charged per second. With 32 kb/s audio, a file whose audio is above 32 kb/s is re-encoded too, as the curl example showed. - Memory. The Google Drive download holds the whole video in n8n's memory. n8n's docs give 640 MiB of RAM on the Trial, Starter and Pro-1 plans and 1,280 MiB on Pro-2, and warn that large binary files can crash the instance. A 300 MB recording may fail before it ever reaches Apify, so use the link route whenever the video already has a direct or signed link (S3, R2, Dropbox).
- The Apify node is Apify's own n8n node, verified by n8n. On n8n Cloud the instance owner first has to allow verified community nodes in the Cloud Admin Panel. Select the Actor By URL or by ID as above: the From list picker may not show it while the Actor is hidden from Store search. Node labels can differ slightly between versions; use what your installed node shows.
- The OpenAI node's model. n8n's OpenAI node (version 2.44) transcribes with
whisper-1, which OpenAI plans to shut down on 26 February 2027. To usegpt-transcribe, call OpenAI's transcription endpoint from an HTTP Request node instead. - Try the workflow with one real long recording before you trust it with a backlog.
Cost
The audio route costs nothing beyond your own machine. The Actor is pay per event, at the same prices on every Apify plan:
- $0.01 per video saved under the limit
- $0.002 per second of output video
- $0.002 more per second when the input is above 1080p (for example 4K phone footage)
- free: targets that are too small, files that end up over the limit, failed downloads and files that are not videos
- plus Apify's standard Actor start event, a fraction of a cent per run ($0.00005 per GB of run memory, so $0.0002 at the 4 GB used in the workflow above)
| Recording | Cost |
|---|---|
| 10 minutes, up to 1080p | $1.21 |
| 10 minutes, 4K phone video | $2.41 |
| 19 minutes, up to 1080p | $2.29 |
| A video already under the limit in a copyable format, copied as it is | $0.01 |
| A target that is too small | $0 |
Apify's free plan includes $5 of usage a month; check the current amount on
Apify's pricing page. Set a maximum charge per run
(maxTotalChargeUsd, as in the examples) and the Actor stops cleanly at that limit.
FAQ
Can I send a YouTube or TikTok link?
No. Those are web pages, not video files, and their terms do not allow downloading. Use a file you have the right to process.
What about longer recordings?
At 24 MB, recordings fit up to about 19 minutes at default audio and about 23 minutes with
audioBitrateKbps 32 (computed with the Actor's own planner, not yet confirmed with real
long recordings). A longer video falls below a watchable picture, so the Actor refuses it, free, and
says the smallest size that would work. For transcription, extract the audio (option 1) or split the
recording into parts first.
Which OpenAI model should I use?
OpenAI recommends gpt-transcribe. whisper-1 and the gpt-4o transcribe models still
work but are scheduled to shut down on 26 February 2027. The 25 MB file limit is the same.
Does the Actor keep my video?
No. Each video is downloaded to the run's temporary disk, encoded and deleted from that disk. The compressed file and the result row are stored only in the run's own storage in your Apify account, which Apify deletes after your plan's data retention period, and location and device metadata are removed from the output. A file you upload to a named key-value store yourself, as in the script, is different: named stores do not expire, so the uploaded original stays there until you delete it. The script and the file-upload workflow above delete it after every run, also when the run fails.
Quarrywise is independent and isn't affiliated with or endorsed by OpenAI, n8n, Apify or the other services named here.