How to Add File Conversion to Your App Without Storing User Files

Sooner or later a product needs to convert a file. A user uploads a Word document and you need a PDF. A customer sends a spreadsheet and your importer only reads CSV. Someone attaches a HEIC photo from an iPhone and your image pipeline has never heard of it. Writing that yourself means installing LibreOffice, ImageMagick, ffmpeg and a dozen parsers, then keeping them patched forever, so most teams reach for an API instead.

The part that gets less attention is what that API does with the file afterwards. This guide covers wiring up conversion end to end - authentication, the request shape, large files, errors and limits - and pays particular attention to the storage question, because it is the one your security reviewer will ask about.

Why file retention is the hard part

Most conversion APIs are built around a job model: you upload a file, you get a job ID, you poll or receive a webhook, then you download the result from a URL. That design is convenient, and it necessarily means the file is written to storage and lives there until some retention window expires. That is not a criticism of the design, it is just how queues work. But it creates real obligations for you.

  • Your data processing agreement now has to cover a third party holding customer files at rest.
  • A download URL is a credential. If it leaks, is guessable, or ends up in a log or a Referer header, the file leaks with it.
  • Retention is a setting, which means it is something that can be misconfigured, and something you have to audit.
  • If a user asks you to delete their data, you need a story for the copy sitting in someone else's bucket.

The alternative is to make the conversion synchronous and stateless: the bytes go up, the converted bytes come straight back down, and nothing is ever written to a disk. You lose the ability to fire and forget, and you gain the ability to answer "where is this file stored?" with "nowhere". The rest of this guide uses the PrivConvert API, which works that way.

Authentication

Every request carries a secret key as a bearer token. Keys begin with pk_live_ and are issued per account from your dashboard.

Authorization: Bearer pk_live_your_key_here

Treat the key as a server-side secret. Because a conversion request is a plain multipart POST, it is tempting to call it straight from the browser, but that would ship your key to every visitor. Proxy through your own backend instead.

Converting a file

One endpoint does the work. The tool you want is the last path segment, the file goes in a file form field, and the converted bytes come back as the response body.

POST https://privconvert.com/api/convert/{tool}
Content-Type: multipart/form-data

In curl that is a one-liner:

curl -X POST https://privconvert.com/api/convert/word-to-pdf \
  -H "Authorization: Bearer $PRIVCONVERT_KEY" \
  -F "[email protected]" \
  -o contract.pdf

In Python, keeping everything in memory so your own service does not create the disk copy you were trying to avoid:

import os, requests

def convert(data: bytes, filename: str, tool: str) -> bytes:
    r = requests.post(
        f"https://privconvert.com/api/convert/{tool}",
        headers={"Authorization": f"Bearer {os.environ['PRIVCONVERT_KEY']}"},
        files={"file": (filename, data)},
        timeout=180,
    )
    if r.status_code != 200:
        err = r.json().get("error", {})
        raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
    return r.content

pdf = convert(open("contract.docx", "rb").read(), "contract.docx", "word-to-pdf")

And in Node, using the built-in fetch and FormData:

async function convert(buffer, filename, tool) {
  const form = new FormData();
  form.append("file", new Blob([buffer]), filename);

  const res = await fetch(
    `https://privconvert.com/api/convert/${tool}`,
    { method: "POST",
      headers: { Authorization: `Bearer ${process.env.PRIVCONVERT_KEY}` },
      body: form }
  );

  if (!res.ok) {
    const { error } = await res.json();
    throw new Error(`${error.code}: ${error.message}`);
  }
  return Buffer.from(await res.arrayBuffer());
}

There is no job ID, no polling loop and no webhook endpoint to build and secure. For a request/response web app this removes a surprising amount of plumbing.

Discovering what you can convert

Do not hardcode a format list - it will drift. GET /api/tools is unauthenticated and cacheable, and returns every conversion with its input format, output format and category:

curl -s https://privconvert.com/api/tools | jq '.count, .categories'

curl -s "https://privconvert.com/api/tools?category=image" \
  | jq -r '.tools[] | "\(.id): \(.input) -> \(.output)"'

This is the endpoint to build a format picker from. If you let users choose a target format, query the catalogue at build time or cache it for an hour, and your UI stays correct as tools are added.

Large files

A single multipart POST is fine for everyday documents and images. For genuinely large inputs there is a three-step chunked flow that avoids one enormous request:

  1. POST /api/upload-init - declare the filename, total size, chunk count and tool. You get back an upload_id.
  2. POST /api/upload-chunk - send each part with its upload_id and chunk_index.
  3. POST /api/convert-chunked - assemble and convert, receiving the converted bytes in the response.

Two limits worth designing around: a session accepts at most 10 chunks, and it expires after 5 minutes of inactivity. So size your chunks as total size divided by ten rather than picking a fixed small chunk, and do not open a session until you are ready to push the parts.

Errors you should actually handle

Failures come back as JSON with a stable machine-readable code, so branch on error.code rather than parsing the message:

{"error": {"code": "file_too_large",
           "message": "File exceeds 250MB limit."}}

The ones that matter in production:

  • bad_request (400) - the file could not be converted. Corrupt, password-protected, or not really the format its extension claims. This is a user problem: show it to them, do not retry.
  • unauthorized (401) - missing or invalid key.
  • file_too_large (413) - over your plan's per-file ceiling.
  • unsupported_type (415) - the upload is not an accepted type for that tool.
  • rate_limited (429) - requests per minute or monthly quota. Respect Retry-After.
  • concurrency_limited (429) - too many conversions in flight at once. Different from the one above: you are not out of quota, you just need to wait for one to finish.
  • server_busy (503) - the platform is at capacity. Back off and retry.

The distinction between rate_limited and concurrency_limited is the one people get wrong. Both are 429, but the fixes are opposite: the first means slow down or upgrade, the second means run fewer conversions in parallel. A worker pool sized to your plan's concurrency avoids the second entirely.

Plans and limits

Pick a plan by the constraint that will bite first, which is usually concurrency or file size rather than the monthly quota:

  • Starter - 5,000 conversions/month, 60 requests/minute, 250 MB per file, 2 concurrent.
  • Growth - 25,000 conversions/month, 120 requests/minute, 500 MB per file, 4 concurrent.
  • Scale - 150,000 conversions/month, 300 requests/minute, 500 MB per file, 6 concurrent.

Current details are on the developers page, and the full reference lives in the API documentation.

What happens to the file on our side

Since the whole premise is that nothing is stored, it is fair to ask what actually runs. Each conversion is handled in server memory on a RAM-backed filesystem, never on a physical disk. Anything that needs a real engine - LibreOffice for documents, ffmpeg for audio, Calibre for eBooks - runs inside a sandbox with no network access at all and a per-job memory cap, and the working directory is destroyed in a finally block when the request ends.

The no-network property does more than prevent data exfiltration. A document can reference remote images, an HTML file can carry tracking pixels, and an email can be full of web beacons. Because the renderer has no route out, none of them are ever fetched - so converting a hostile file cannot phone home, and cannot be used to probe your network or ours.

A reasonable integration checklist

  • Keep the API key server-side and never let it reach the browser.
  • Branch on error.code, not on message text, which is meant for humans and may change.
  • Show 400-class failures to the user; retry only 429 and 503, with backoff and Retry-After.
  • Size your worker pool to your plan's concurrency limit so you never generate concurrency_limited.
  • Build format pickers from GET /api/tools instead of a hardcoded list.
  • Set a generous client timeout. Large documents legitimately take tens of seconds.
  • Stream the response straight to your user if you can, so your own service does not create the stored copy you were avoiding.

If your reason for reaching for an API in the first place was to avoid running conversion infrastructure, it is worth making sure you did not simultaneously acquire a file-retention obligation to go with it.

Get started:
Developer API API documentation How it works