Migrate from CloudConvert
switchfile accepts CloudConvert v2 jobs. Change the base URL and the API key, then check the few differences listed on this page. Webhooks are set per job with webhook_url. Webhooks for the whole account, set in the CloudConvert dashboard, do not exist at switchfile.
Three steps
- Step 1
Create a key
- Step 2
Change the base URL and the key
- Step 3
Set webhooks per job
What to change
The same job, new address
Import a file from a URL, convert it to MP4 and get a download link. The comments show the old value. Uploads through import/upload and result.form work the same way.
# was: https://api.cloudconvert.com/v2/jobs and your CloudConvert key. Needs jq. RES=$(curl -sS https://api.switchfile.io/v2/jobs \ -H "Authorization: Bearer $SWITCHFILE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tasks": { "import": { "operation": "import/url", "url": "https://example.com/talk.mkv" }, "convert": { "operation": "convert", "input": "import", "output_format": "mp4" }, "export": { "operation": "export/url", "input": "convert" } } }') # Errors come back as {"message", "code"} with the HTTP status, as at CloudConvert. JOB_ID=$(echo "$RES" | jq -er .data.id) || { echo "$RES" | jq -r '"\(.code): \(.message)"' >&2; exit 1; } # /wait answers when the job ends, or after a server-side timeout with the job still running. RES=$(curl -sS --max-time 360 https://api.switchfile.io/v2/jobs/$JOB_ID/wait \ -H "Authorization: Bearer $SWITCHFILE_API_KEY") case $(echo "$RES" | jq -r .data.status) in finished) echo "$RES" | jq -r '.data.tasks[] | select(.name == "export") | .result.files[0].url' ;; error) echo "$RES" | jq -r '.data.tasks[] | select(.status == "error") | "\(.name) failed: \(.code) \(.message)"' >&2; exit 1 ;; waiting|processing) echo "Job $JOB_ID is still running: call /wait again or send webhook_url" >&2; exit 1 ;; *) echo "$RES" | jq -r '"\(.code): \(.message)"' >&2; exit 1 ;; esac
// was: https://api.cloudconvert.com/v2 and your CloudConvert key const BASE = "https://api.switchfile.io/v2"; const headers = { Authorization: `Bearer ${process.env.SWITCHFILE_API_KEY}`, "Content-Type": "application/json", }; async function api(path, init = {}) { // /wait answers when the job ends or after a server-side timeout; allow a little longer here. const res = await fetch(`${BASE}${path}`, { ...init, headers, signal: AbortSignal.timeout(360_000) }); const body = await res.json().catch(() => ({})); // Errors come back as { message, code } with the HTTP status, as at CloudConvert. if (!res.ok) throw new Error(`${res.status} ${body.code}: ${body.message}`); return body.data; } const job = await api("/jobs", { method: "POST", body: JSON.stringify({ tasks: { import: { operation: "import/url", url: "https://example.com/talk.mkv" }, convert: { operation: "convert", input: "import", output_format: "mp4" }, export: { operation: "export/url", input: "convert" }, }, }), }); const done = await api(`/jobs/${job.id}/wait`); if (done.status === "error") { const failed = done.tasks.find((t) => t.status === "error"); throw new Error(`Task ${failed?.name} failed: ${failed?.code} ${failed?.message}`); } if (done.status !== "finished") { // Timed out while the job is still running: call /wait again, or send webhook_url with the job. throw new Error(`Job ${done.id} is still ${done.status}`); } const file = done.tasks.find((t) => t.name === "export").result.files[0]; console.log(file.filename, file.url);
<?php // composer require guzzlehttp/guzzle // was: https://api.cloudconvert.com/v2/ and your CloudConvert key $http = new GuzzleHttp\Client([ 'base_uri' => 'https://api.switchfile.io/v2/', 'headers' => ['Authorization' => 'Bearer ' . getenv('SWITCHFILE_API_KEY')], // /wait answers when the job ends or after a server-side timeout; allow a little longer here. 'timeout' => 360, ]); function fail(string $message) { fwrite(STDERR, $message . PHP_EOL); exit(1); } try { $job = json_decode($http->post('jobs', ['json' => ['tasks' => [ 'import' => ['operation' => 'import/url', 'url' => 'https://example.com/talk.mkv'], 'convert' => ['operation' => 'convert', 'input' => 'import', 'output_format' => 'mp4'], 'export' => ['operation' => 'export/url', 'input' => 'convert'], ]]])->getBody(), true)['data']; $done = json_decode($http->get("jobs/{$job['id']}/wait")->getBody(), true)['data']; } catch (GuzzleHttp\Exception\GuzzleException $e) { // HTTP errors carry {"message", "code"} as at CloudConvert; timeouts and network errors have no response. $res = $e instanceof GuzzleHttp\Exception\RequestException ? $e->getResponse() : null; $body = $res ? json_decode($res->getBody(), true) : null; fail('Request failed: ' . (isset($body['code']) ? "{$body['code']}: {$body['message']}" : $e->getMessage())); } if ($done['status'] === 'error') { foreach ($done['tasks'] as $task) { if ($task['status'] === 'error') fail("Task {$task['name']} failed: {$task['code']} {$task['message']}"); } } if ($done['status'] !== 'finished') { // Timed out while the job is still running: call /wait again, or send webhook_url with the job. fail("Job {$done['id']} is still {$done['status']}"); } foreach ($done['tasks'] as $task) { if ($task['name'] === 'export') echo $task['result']['files'][0]['url'], PHP_EOL; }
import os import requests # was: https://api.cloudconvert.com/v2 and your CloudConvert key BASE = "https://api.switchfile.io/v2" headers = {"Authorization": f"Bearer {os.environ['SWITCHFILE_API_KEY']}"} def api(method, path, **kwargs): # /wait answers when the job ends or after a server-side timeout; allow a little longer here. # A timeout on our side raises requests.Timeout. r = requests.request(method, f"{BASE}{path}", headers=headers, timeout=360, **kwargs) if not r.ok: # Errors come back as {"message", "code"} with the HTTP status, as at CloudConvert. try: err = r.json() except ValueError: err = {} raise RuntimeError(f"{r.status_code} {err.get('code')}: {err.get('message')}") return r.json()["data"] job = api("POST", "/jobs", json={"tasks": { "import": {"operation": "import/url", "url": "https://example.com/talk.mkv"}, "convert": {"operation": "convert", "input": "import", "output_format": "mp4"}, "export": {"operation": "export/url", "input": "convert"}, }}) done = api("GET", f"/jobs/{job['id']}/wait") if done["status"] == "error": failed = next(t for t in done["tasks"] if t["status"] == "error") raise RuntimeError(f"Task {failed['name']} failed: {failed['code']} {failed['message']}") if done["status"] != "finished": # Timed out while the job is still running: call /wait again, or send webhook_url with the job. raise RuntimeError(f"Job {done['id']} is still {done['status']}") export = next(t for t in done["tasks"] if t["name"] == "export") print(export["result"]["files"][0]["url"])
Webhooks
Webhooks are set per job with webhook_url. Webhooks for the whole account, set in the CloudConvert dashboard, do not exist at switchfile. The same goes for webhooks created through /v2/webhooks: add webhook_url to each job instead. We call webhook_url with job.finished or job.failed and the job in the body, signed with HMAC-SHA256 in the CloudConvert-Signature header. The signature check stays the same; only the secret changes.
import { createHmac, timingSafeEqual } from "node:crypto"; // Same header as before. Only the secret changes: copy it from Dashboard > Webhooks. function isValid(rawBody, signature, secret = process.env.SWITCHFILE_WEBHOOK_SECRET) { const expected = Buffer.from(createHmac("sha256", secret).update(rawBody).digest("hex")); const given = Buffer.from(signature ?? ""); // Compare byte lengths first: timingSafeEqual throws on buffers of different length. return given.length === expected.length && timingSafeEqual(given, expected); } // isValid(rawBody, req.headers["cloudconvert-signature"])
<?php // Same header as before. Only the secret changes: copy it from Dashboard > Webhooks. $raw = file_get_contents('php://input'); $expected = hash_hmac('sha256', $raw, getenv('SWITCHFILE_WEBHOOK_SECRET')); $valid = hash_equals($expected, $_SERVER['HTTP_CLOUDCONVERT_SIGNATURE'] ?? '');
import hashlib import hmac import os # Same header as before. Only the secret changes: copy it from Dashboard > Webhooks. def is_valid(raw_body: bytes, signature: str) -> bool: secret = os.environ["SWITCHFILE_WEBHOOK_SECRET"].encode() expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest().encode() # Compare bytes: compare_digest raises TypeError for str with non-ASCII characters. return hmac.compare_digest(expected, (signature or "").encode()) # is_valid(request.body, request.headers.get("CloudConvert-Signature"))
Differences to check
From our own comparison of both APIs on 7 November 2026.