Replace Cloudinary video on one step, not the whole library.

Your Cloudinary bill has exactly one line that grows: video transformations. The images are fine, the storage is fine, and it's the 400 user uploads a month that get cropped to 9:16 and compressed that keep climbing.
You can replace Cloudinary video transformations without moving a single asset. Leave storage, image delivery and the asset manager where they are, send the one expensive video job to a processing API that takes your existing delivery URL as input and hands the output file back, then write that file into your own bucket. The hard way is the migration every guide assumes: copy the library out, map every public_id to a new object key, rewrite every URL your app builds. The narrow swap touches one code path, costs per input second with failed jobs unbilled, and rolls back by pointing that code path at the old URL again.
What a full Cloudinary exit actually involves
A real exit is a week of work, and almost none of it is copying bytes. The copying is already solved: gumlet/cloudinary-to-s3 is a Python 3.6+ script that takes your cloud name, API key and secret plus an S3 endpoint, access key, secret and bucket, runs 25 parallel workers, and ships a --resuming_migration flag. The author expected the copy to break halfway.
What no script does is the part that blocks the cutover. Nobody rewrites the public_id references in your database, your CMS records, your cached HTML or the email templates rendered last March. Cloudinary offers no bulk download in its UI, so exporting your originals means scripting it against the SDK, as Pierre Hérault documented while moving a Strapi library off it (Medium). A delivery URL encodes the cloud name, asset type, delivery type, transformation string, version and public ID in one path, so the library doesn't map as a single folder: image, video and raw assets with upload, private and authenticated delivery each need their own mapping.
None of that work makes your video transformation cheaper. It moves the cost to a different meter.
The narrow swap, step by step
The swap is a change to one function. Storage stays, image URLs stay, the asset manager stays, and the only thing that leaves is the render.
- Find the code path that produces the video rendition. In most apps it's one helper that builds a transformation string, or one eager transformation set at upload.
- Replace it with a job: POST the existing source URL to
https://api.ffmpeg-micro.com/v1/transcodes, pollGET /v1/transcodes/:iduntil the status iscompletedorfailed, thenGET /v1/transcodes/:id/downloadfor a signed URL. - Stream that output into the bucket you already own and store the key next to the existing Cloudinary reference, not instead of it.
- Read the new key when it's present and fall back to the old URL when it isn't.
Step 4 is the whole safety story. Two columns on one row, video_key and cloudinary_public_id, make the change a feature flag per asset rather than a cutover. Reverting is a deploy, not a restore.
Public Cloudinary delivery URLs pass straight through as the job's input, so nothing lands on your own disk first. For private or authenticated delivery types, generate a signed URL or use the presigned upload flow in the docs. Input caps are 100 MB on the free tier and 1,024 MB on Starter, which covers the clips this pattern deals with.
Three shapes the swap takes in real code
The three shapes are a background job inside your app, a plain HTTP call where no SDK exists, and an HTTP step in a workflow tool. Job semantics are identical in all three: submit, poll, download. There's no completion callback, so build the wait into the worker.
A queue job in PHP or Python
Put the swap in the worker you already run for thumbnails or email. The Python package is ffmpeg-micro on PyPI (Python 3.8+) and the PHP one is ffmpeg-micro/ffmpeg-micro-php on Packagist (PHP 8.1+); both wrap the same three calls shown here.
import os, time, requests, boto3
API = "https://api.ffmpeg-micro.com/v1"
H = {"Authorization": f"Bearer {os.environ['FFMPEG_MICRO_KEY']}",
"Content-Type": "application/json"}
@app.task(bind=True, max_retries=3)
def reframe_listing_video(self, asset_id, cloudinary_url, s3_key):
job = requests.post(f"{API}/transcodes", headers=H, json={
"inputs": [{"url": cloudinary_url}],
"outputFormat": "mp4",
"preset": {"quality": "medium", "resolution": "1080p"},
}).json()
status = None
while status is None or status["status"] not in ("completed", "failed"):
time.sleep(5)
status = requests.get(f"{API}/transcodes/{job['id']}", headers=H).json()
if status["status"] == "failed":
raise self.retry(countdown=60)
signed = requests.get(f"{API}/transcodes/{job['id']}/download",
headers=H).json()["url"]
boto3.client("s3").upload_fileobj(
requests.get(signed, stream=True).raw, "listing-media", s3_key)
Asset.objects.filter(id=asset_id).update(video_key=s3_key)
The last line is the flag flip. Until it runs, every page still serves the Cloudinary URL. Jobs with inputs longer than two minutes run in a separate lane from short ones, so a long encode can't delay the 45-second clips behind it. Why your transcode job failed breaks the statuses down by cause for that failed branch.
A plain HTTP call from Node
There is no Node SDK, and you don't need one. Node 18+ has fetch, and the three endpoints are plain JSON.
const API = "https://api.ffmpeg-micro.com/v1";
const h = { Authorization: `Bearer ${process.env.FFMPEG_MICRO_KEY}`,
"Content-Type": "application/json" };
async function reframe(sourceUrl) {
const res = await fetch(`${API}/transcodes`, {
method: "POST", headers: h,
body: JSON.stringify({
inputs: [{ url: sourceUrl }],
outputFormat: "mp4",
preset: { quality: "medium", resolution: "1080p" },
}),
});
const { id } = await res.json();
let job;
do {
await new Promise((r) => setTimeout(r, 5000));
job = await (await fetch(`${API}/transcodes/${id}`, { headers: h })).json();
} while (job.status !== "completed" && job.status !== "failed");
if (job.status === "failed") throw new Error(`transcode ${id} failed`);
const { url } = await (
await fetch(`${API}/transcodes/${id}/download`, { headers: h })
).json();
return url; // signed HTTPS URL, stream it into your own S3 PUT
}
For a working key and a finished job before you write any of this, the quickstart runs the full sequence after sign-up: a temporary key, an upload, a transcode, the poll loop and the download.
An n8n or Make HTTP step
The same three calls work as workflow nodes, the one place Cloudinary's video transformations were never reachable. More than 300 builders already run these jobs from Make or n8n. In n8n, use an HTTP Request node to POST the job, a Wait node of 5 to 10 seconds, a second HTTP Request to the status endpoint, and an IF node that loops back to Wait until the status is completed. A third HTTP Request fetches the download URL, and the S3 node writes it to your bucket. Make uses the same four modules with a sleep. Checking status, listing jobs and downloading are free, so a chatty poll loop costs nothing. Add an idempotency key on the submit step, because an n8n workflow that runs twice renders every video twice.
One workload, priced on both meters
Take a marketplace app with 400 user-uploaded listing videos a month, averaging 45 seconds each, where every upload gets one output: 1080p, reframed to 9:16, H.264.
Cloudinary's published credit model counts most video transformations per second of video: each second of SD counts as two transformations and HD as four, partial seconds round up, and 1,000 transformations make a credit. Eager transformations count the same as on-the-fly ones, so pre-generating the rendition doesn't discount it. We worked that arithmetic out in what a Cloudinary credit actually buys for video.
| Cloudinary credits | Per input second | |
|---|---|---|
| Unit of one 45s HD render | 180 transformations (0.18 credits) | 4.5 tokens |
| 400 renders a month | 72 credits | 1,800 tokens |
| Monthly cost | about $31.68 at $0.44 a credit on the published $99 / 225-credit plan | $19.99 Starter, 2,500 tokens, 700 left over |
| Cost per video | about $0.079 | about $0.036 |
| Rounding | 45.4s bills as 46s | metered per second, no rounding |
| 12 corrupt uploads that fail | counted as attempted work | billed at zero |
| Output stored after the job | derived asset counts against the storage quota every month | your bucket, your rate |
Two things in that table matter more than the headline difference. A credit is fungible across transformations, managed storage and delivery bandwidth, so video renders compete for budget with the images you're happy with. And the credit side is only this cheap because the renditions are eager; if the transformation runs on delivery, the per-second count repeats with traffic instead of content, which is a surprise the month a listing goes viral. One token buys 10 seconds of input, so the bill tracks video duration, not server time. The 700 leftover tokens roll over while you stay subscribed, up to three times the monthly grant.
When keeping Cloudinary's video transformations is the right call
Keep the URL-based transformation when the variants are genuinely unpredictable. If viewers generate dozens of renditions per asset on demand and you'd otherwise pre-render combinations nobody requests, a transformation CDN with a derived-asset cache does real work a job queue would have to reimplement. Same answer if you depend on automatic format negotiation per browser, or if your video ships as adaptive streaming with a player attached, which is a streaming platform's job, not a processing API's. If you're leaving for vendor consolidation or a compliance rule about where assets live, the narrow swap doesn't help and the full migration is the work, mapping CSV and all.
The swap fits the opposite case: a fixed number of known outputs per asset, produced once, consumed by your own pipeline. That's most apps. For which alternatives hand the output file back instead of hosting it, see which Cloudinary alternatives hand the file back, and for the URL-to-job translation, one job, not a URL.
FAQ about replacing Cloudinary video transformations
Can I keep Cloudinary for images and use something else for video?
Yes, and this is the normal arrangement rather than a workaround. Cloudinary keeps serving your images and holding your library, while the video transformation becomes an API job whose output you write wherever you already store files. The job takes a public URL as input and returns a signed URL, so neither system needs to know about the other.
Do I have to migrate off Cloudinary video all at once?
No. Replacing one video transformation is a per-asset change, so both paths can run at once: new uploads go through the API, existing renditions stay put. Old assets keep working on their original delivery URLs until you have a reason to touch them.
Does the processing API call me back when the job is done?
No. FFmpeg Micro uses poll-based job semantics: submit the job, poll GET /v1/transcodes/:id until the status reads completed or failed, then request a download URL. There are no completion webhooks, so a webhookUrl in the request body is ignored and your worker or workflow does the waiting. Polling and downloading are both free.
How do I roll back if the swap goes wrong?
Store the new object key in a separate column from the Cloudinary reference and read the old one whenever the new one is null. Rollback is a one-line change in the read path, no restore and no re-upload, because the original asset was never moved or deleted.
What happens to a transformed output after the job finishes?
The output file lands in your own bucket, billed at your own storage rate, with no second meter against it. On the credit model, that output is a derived asset counting against the storage quota for as long as you keep it, so last quarter's renditions are still on this quarter's bill.
If your situation is the one in the table, 400 uploads a month and one rendition each, the test that settles it is small: point one job at one real Cloudinary delivery URL, watch the poll loop reach completed, and drop the output into your bucket next to the old reference. Sign up free gives you a key and 200 tokens, roughly 33 minutes of video, no card and nothing to install: no servers to run, no FFmpeg binary, and the same three calls from your app code, from n8n, Make and Zapier, or from an AI agent.
About Javid Jamae
Founder & CEO at FFmpeg Micro
Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.
You might also like

Cloudinary video transformation alternative: one job, not a URL
The Cloudinary video transformation alternative mapped parameter by parameter: w_, c_fill, so_/eo_, l_text, and the three things that have no equivalent.

Cloudinary alternative for video: which ones hand the file back
Most Cloudinary alternative lists rank image CDNs and hosting platforms. Sort the field into four kinds and see which ones hand the output file back to you.

Cloudinary pricing in 2026: what a credit actually buys for video
Cloudinary pricing in 2026, priced in videos instead of credits: the live plans, what a credit buys, and one 90-second 1080p job through all three meters.
Ready to process videos at scale?
Start using FFmpeg Micro's simple API today. No infrastructure required.
Get Started Free