Concepts
Errors
Every error code, what triggers it, and how to handle it.
Every error response uses the same envelope:
{
"error": {
"code": "quota_exceeded",
"message": "Monthly conversion quota exceeded.",
"details": { "limit": 1000, "used": 1000 }
}
}| Field | Description |
|---|---|
code | Machine-readable, stable across versions. Always present. |
message | Human-readable. May change wording over time — do not pattern-match. |
details | Optional structured context (e.g. limit, used, retry_after, route). |
Error codes #
| HTTP | code | Meaning | Recovery |
|---|---|---|---|
400 | bad_request | Missing field, malformed multipart, invalid JSON. | Fix the request. |
401 | invalid_api_key | Header missing or revoked key. | Re-issue from the dashboard. |
404 | not_found | Job ID doesn't exist or has expired (7 day retention). | — |
409 | bad_request | Idempotency-Key request is still in-flight. | Retry after Retry-After. |
413 | file_too_large | File exceeds the active plan's max size. | Upgrade or use POST /v1/jobs for >100 MB. |
422 | unsupported_route | This source→target combo isn't in our routing table. | Check Formats. |
422 | bad_request | Same Idempotency-Key reused with a different payload. | Issue a new key. |
429 | rate_limited | Per-minute throttle exceeded. | Honor Retry-After. |
429 | quota_exceeded | Monthly quota hit. | Wait for month rollover or upgrade. |
502 | service_unavailable | Conversion engine temporarily unavailable. | Safe to retry with back-off. |
401 | invalid_signature | Stripe webhook signature mismatch (internal endpoint). | — |
Recommended handling #
Branch on the HTTP status (or, when present, error.code):
Python
import requests, time
KEY = "ctf_sk_..."
H = {"Authorization": f"Bearer {KEY}"}
def convert(path: str, target: str) -> bytes:
with open(path, "rb") as f:
r = requests.post(
"https://changethisfile.com/v1/convert",
headers=H, files={"file": f}, data={"target": target},
)
if r.status_code == 200:
return r.content
err = (r.json().get("error") or {}) if r.headers.get("Content-Type", "").startswith("application/json") else {}
code = err.get("code", "unknown")
if code == "quota_exceeded":
raise RuntimeError(f"Out of quota: {err.get('details')}")
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 60)))
return convert(path, target) # retry once
if r.status_code == 413:
raise RuntimeError("File too large for current plan")
if r.status_code == 422:
raise RuntimeError(f"Unsupported route: {err.get('message')}")
if r.status_code in (502, 503):
time.sleep(2)
return convert(path, target) # engine briefly down
if r.status_code == 401:
raise RuntimeError("Bad CTF API key")
raise RuntimeError(f"[{code}] {err.get('message')} (CF-Ray={r.headers.get('CF-Ray')})")JavaScript
async function convert(path, target) {
const { readFileSync } = await import('node:fs');
const form = new FormData();
form.append('file', new Blob([readFileSync(path)]), path);
form.append('target', target);
const r = await fetch('https://changethisfile.com/v1/convert', {
method: 'POST',
headers: { Authorization: 'Bearer ctf_sk_...' },
body: form,
});
if (r.ok) return new Uint8Array(await r.arrayBuffer());
const err = (await r.json().catch(() => ({}))).error || {};
if (err.code === 'quota_exceeded') throw new Error(`Out of quota: ${JSON.stringify(err.details)}`);
if (r.status === 429) {
await new Promise(rs => setTimeout(rs, (parseInt(r.headers.get('Retry-After')) || 60) * 1000));
return convert(path, target);
}
if (r.status === 413) throw new Error('File too large');
if (r.status === 422) throw new Error(`Unsupported route: ${err.message}`);
if (r.status === 502 || r.status === 503) {
await new Promise(rs => setTimeout(rs, 2000));
return convert(path, target);
}
if (r.status === 401) throw new Error('Bad CTF API key');
throw new Error(`[${err.code}] ${err.message} (CF-Ray=${r.headers.get('CF-Ray')})`);
}Request IDs #
Every response includes either an X-Request-Id (synthetic) or a CF-Ray (Cloudflare-issued) header. Include it when contacting support — it lets us pinpoint your specific call in seconds.