Skip to main content
Developer API

Convert files from your code.

One REST endpoint, one header. Send a file or a URL, poll for a signed download link. The same engines that power filewhirl.com, no JWT, no SDK required.

x-api-key auth

No JWT, no Authorization header: call it from any backend.

Private by default

Files deleted after 1 hour; signed URLs only.

Usage-metered

$29/mo plan + pay-as-you-go credits. Failed jobs are refunded.

Authentication

Create a key on your account page and send it on every request:

header
x-api-key: ff_live_your_key_here

Keys start with ff_live_ and are shown only once. Keep them server-side: anyone with a key can spend your quota.

Create a job

POST https://fkgvpgpdkktikfzspnib.supabase.co/functions/v1/convert-api accepts a file as multipart, or send a public URL as JSON. The to field is the target format (it must differ from the input format, same-format requests return 400). Returns 202 with an id, token and status_url.

cURL. Upload a file
curl -X POST https://fkgvpgpdkktikfzspnib.supabase.co/functions/v1/convert-api \
  -H "x-api-key: ff_live_your_key_here" \
  -F "file=@photo.png" \
  -F "to=jpg"
cURL. Convert from a URL
curl -X POST https://fkgvpgpdkktikfzspnib.supabase.co/functions/v1/convert-api \
  -H "x-api-key: ff_live_your_key_here" \
  -H "content-type: application/json" \
  -d '{ "to": "mp3", "url": "https://example.com/clip.mp4" }'

Poll for the result

GET https://fkgvpgpdkktikfzspnib.supabase.co/functions/v1/convert-api?id=…&token=… every ~1.5s until status is done or error. On success you get a signed output_url valid for one hour.

cURL: poll status
curl "https://fkgvpgpdkktikfzspnib.supabase.co/functions/v1/convert-api?id=JOB_ID&token=JOB_TOKEN" \
  -H "x-api-key: ff_live_your_key_here"

Webhooks

Instead of polling, pass a callback_url and FileWhirl will POST to it when the job finishes. Callbacks are signed with a secret that belongs to your API key, so create one first. It is generated on our side and shown once: store it. Sending the same request again returns 409; send { "webhook_secret": { "rotate": true } } to replace it, after which the old secret stops working.

cURL. Create the webhook secret
curl -X POST https://fkgvpgpdkktikfzspnib.supabase.co/functions/v1/convert-api \
  -H "x-api-key: ff_live_your_key_here" \
  -H "content-type: application/json" \
  -d '{ "webhook_secret": {} }'
cURL. Convert with a callback
curl -X POST https://fkgvpgpdkktikfzspnib.supabase.co/functions/v1/convert-api \
  -H "x-api-key: ff_live_your_key_here" \
  -H "content-type: application/json" \
  -d '{ "to": "mp3", "url": "https://example.com/clip.mp4", "callback_url": "https://your-app.example.com/filewhirl" }'

The callback is a JSON POST with the event type conversion.completed or conversion.failed (the failed event carries error instead of output_size). Fetch the download link by polling the job once, as above. Each request carries three headers: FileWhirl-Signature, FileWhirl-Event-Id and FileWhirl-Event-Type.

Callback body
{
  "id": "6f0c2d1e-0d7b-4c55-9a43-2f5d8f1b7c10",
  "type": "conversion.completed",
  "created_at": "2026-10-03T12:00:00Z",
  "data": {
    "job_id": "JOB_ID",
    "status": "done",
    "from_format": "mp4",
    "to_format": "mp3",
    "file_name": "clip.mp4",
    "output_size": 482113
  }
}

The signature header looks like t=1790000000,v1=5257a8.... v1 is the hex HMAC-SHA256, keyed with your secret, of the timestamp, a dot and the raw request body. Recompute it, compare in constant time, and reject any request whose t is more than 5 minutes from your clock: that refuses a replay of an old, correctly signed request.

Node.js. Verify a callback
import { createHmac, timingSafeEqual } from "node:crypto";

// Use the RAW body exactly as received, before any JSON parsing.
function verify(header, rawBody, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const got = Buffer.from(parts.v1 ?? "");
  return got.length === expected.length && timingSafeEqual(got, Buffer.from(expected));
}
Python. Verify a callback
import hashlib, hmac, time

# Use the RAW body exactly as received, before any JSON parsing.
def verify(header, raw_body, secret, tolerance_seconds=300):
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > tolerance_seconds:
        return False
    expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(parts.get("v1", ""), expected)
  • Reply with any 2xx within 10 seconds. Anything else, a timeout or a redirect counts as a failure.
  • Failures are retried up to 6 attempts in total with growing, randomised gaps, about 30 minutes end to end. Use FileWhirl-Event-Id to ignore a repeat.
  • Redirects are never followed. The URL must be https on port 443 or 1024 to 65535 and must resolve to a public address; private, loopback and link-local addresses are refused.
  • Revoking the key stops its callbacks, including any still waiting to retry.

Quickstarts

Each one creates a job and polls it to a download link. Set FILEWHIRL_API_KEY in your environment and run it as is.

Node.js
// Node 20 or later: fetch, FormData and openAsBlob are built in.
import { openAsBlob } from "node:fs";

const API = "https://fkgvpgpdkktikfzspnib.supabase.co/functions/v1/convert-api";
const KEY = process.env.FILEWHIRL_API_KEY;

// 1. Create a job (upload a file)
const form = new FormData();
form.append("file", await openAsBlob("photo.png"), "photo.png");
form.append("to", "jpg");

const res = await fetch(API, { method: "POST", headers: { "x-api-key": KEY }, body: form });
const { id, token, status_url } = await res.json();

// 2. Poll until done (every ~1.5s; outputs are ready within seconds-minutes)
let job;
do {
  await new Promise((r) => setTimeout(r, 1500));
  job = await (await fetch(status_url, { headers: { "x-api-key": KEY } })).json();
} while (job.status === "queued" || job.status === "processing");

if (job.status === "done") console.log("Download:", job.output_url);
else throw new Error(job.error);
Python
import os, time, requests

API = "https://fkgvpgpdkktikfzspnib.supabase.co/functions/v1/convert-api"
KEY = os.environ["FILEWHIRL_API_KEY"]
H = {"x-api-key": KEY}

# 1. Create a job (or pass JSON {"to": "...", "url": "..."} instead of a file)
with open("photo.png", "rb") as f:
    r = requests.post(API, headers=H, files={"file": f}, data={"to": "jpg"})
job = r.json()
sid, token = job["id"], job["token"]

# 2. Poll until done
while True:
    time.sleep(1.5)
    s = requests.get(API, headers=H, params={"id": sid, "token": token}).json()
    if s["status"] in ("done", "error"):
        break

print(s.get("output_url") or s["error"])
cURL. Large files, through an upload URL
# 1. Ask for an upload URL (no job is created and nothing is metered)
curl -X POST https://fkgvpgpdkktikfzspnib.supabase.co/functions/v1/convert-api \
  -H "x-api-key: ff_live_your_key_here" \
  -H "content-type: application/json" \
  -d '{ "upload": { "file_name": "interview.wav", "size": 105815084 } }'
# returns { "upload_url": "...", "input_path": "...", "expires_in": 7200, ... }

# 2. PUT the bytes straight to the upload_url
curl -X PUT "UPLOAD_URL" --upload-file interview.wav

# 3. Queue the job over the uploaded file
curl -X POST https://fkgvpgpdkktikfzspnib.supabase.co/functions/v1/convert-api \
  -H "x-api-key: ff_live_your_key_here" \
  -H "content-type: application/json" \
  -d '{ "to": "mp3", "input_path": "INPUT_PATH" }'

API reference

The OpenAPI 3.1 spec describes every request field, response body and status code the endpoint returns, and a test holds it to the code that runs. Load it into any OpenAPI tool to browse it or generate a client, or import the Postman collection.

Responses & status codes

CodeMeaning
202Job created: returns { id, token, status, billing, status_url, notify_email, callback_url }. Pass an optional notify_email to be emailed when the job finishes; notify_email echoes the accepted address (or null). callback_url is present when you sent one.
201Webhook secret created: { webhook_secret, rotated, note }, shown once. A rotate returns 200.
200Poll result, { status, output_url, output_size, engine, error }. Also the answer to an upload request, { upload_url, upload_method, input_path, max_bytes, expires_in, next }, and to a webhook secret rotate.
400Bad request: missing `to`, no file, url or input_path, an invalid url or format, the same format on both sides, an empty upload, an invalid `notify_email`, or an invalid `callback_url`.
401Invalid or missing x-api-key.
402No usage left: subscribe or buy credits ({ error, reason, hint }). reason is no_api_plan_or_credits or monthly_limit_reached_and_no_credits.
403The input_path is not one of your uploads.
404Unknown job id and token on a poll, or the input_path holds nothing yet (PUT the bytes to its upload_url first).
409A callback_url was sent but the key has no webhook secret yet, or a secret already exists and the request did not ask to rotate it.
413Too large for the path used ({ error, limit_bytes, file_bytes }). Refused before metering, so no unit is spent; an inline refusal also lists the upload URL steps.
500Server error (upload, metering or internal). Any unit spent is refunded; safe to retry.
503A temporary fault on our side, with a Retry-After header. Your key and job are fine: wait and retry, do not resubmit.
  • Up to 2 GB per file (64 MB posted inline; larger files use an upload URL).
  • Output URLs are signed and expire after 1 hour.
  • One usage unit per job; failed-to-queue jobs are auto-refunded.
  • No JWT or Supabase header needed, just x-api-key.

A real request and response

Copied from one run against the live API. The key and the job token are masked; everything else is as it was returned.

1. Request
curl -X POST https://fkgvpgpdkktikfzspnib.supabase.co/functions/v1/convert-api \
  -H "x-api-key: ff_live_****************************************" \
  -F file=@harbour-lane-price-list.docx -F to=pdf
2. Response
HTTP 202
{
  "id": "9361d164-9683-4d0a-80c1-a6ac417c27e5",
  "token": "****",
  "status": "queued",
  "billing": "credits",
  "status_url": "https://fkgvpgpdkktikfzspnib.supabase.co/functions/v1/convert-api?id=9361d164-9683-4d0a-80c1-a6ac417c27e5&token=****",
  "notify_email": null,
  "callback_url": null
}
3. Poll
curl "https://fkgvpgpdkktikfzspnib.supabase.co/functions/v1/convert-api?id=9361d164-9683-4d0a-80c1-a6ac417c27e5&token=****" \
  -H "x-api-key: ff_live_****************************************"

HTTP 200
{
  "status": "processing",
  "output_url": null,
  "output_size": null,
  "engine": null,
  "error": null
}
4. Result
HTTP 200
{
  "status": "done",
  "output_url": "https://fkgvpgpdkktikfzspnib.supabase.co/storage/v1/object/sign/ff-outputs/9361d164-9683-4d0a-80c1-a6ac417c27e5/output-56aac280ca40.pdf?token=****&download=harbour-lane-price-list.pdf",
  "output_size": 111439,
  "engine": "libreoffice",
  "error": null
}

Common questions

How do I get an API key?+

Subscribe to the API plan (or buy pay-as-you-go credits) on your account page, then create a key under API keys. Keys look like ff_live_… and are shown once; store it securely.

Do I need a Supabase or Authorization header?+

No. The endpoint is authenticated solely by the x-api-key header, no JWT, no Authorization, no apikey header. That's why it's safe to call from any backend.

What can I convert?+

Anything the website supports: images, video, audio, documents and archives. The full format matrix is the same one powering filewhirl.com. Note the target format must differ from the input (same-format compression isn't available via the API yet. Use the compress tools on the website).

How is usage billed?+

One unit per job. The API plan includes a monthly allowance; once it's used up, jobs draw from pay-as-you-go credits. If neither has room the API returns 402 with a reason. You're never charged for a job that fails to queue (units are refunded on insert failure). The free website plan includes no API allowance, so a key needs the API plan or credits; without either, the API returns 402 with the reason no_api_plan_or_credits.

How big can files be and how long are outputs available?+

Up to 2 GB per file, with one thing to know about how you send it. Posting the file inline as multipart is capped at 64 MB, because the whole request body has to be held in memory; over that you get a 413 saying so, before any usage unit is spent. For anything larger, ask for an upload URL, PUT the bytes straight to it, then queue the job with the input_path it returns. A public url works at any size too. Output download URLs are signed and valid for 1 hour; both the upload and the result are deleted after that window.

Is there a rate limit?+

There's no hard HTTP rate limit today: throughput is governed by your plan's monthly quota. Be a good citizen: poll a job's status every ~1.5s rather than in a tight loop.

Ready? Get an API key · see pricing · browse the tools · OpenAPI spec · Postman collection.