# Transform Multipart File Uploads

When a [Source](/docs/sources) receives a `multipart/form-data` request, a [Transformation](/docs/transformations) receives its form fields as an object. Each file is described by its `filename`, `content_type` and `size`, and has `content`, a `Buffer` holding the file's bytes.

You can read files, replace or rename them, add new files, and remove files. This guide converts an uploaded CSV file to a JSON event, then shows how to edit the files in a multipart request.

> File content is available on connections that use binary-safe multipart handling. Connections created before October 1, 2026, 18:40 UTC use legacy handling; see the [multipart migration guide](/docs/guides/multipart-binary-migration) to move one. For how Hookdeck parses multipart requests, see [handle multipart requests](/docs/guides/multipart-requests).

## Convert a CSV upload to JSON

A form uploads a CSV file in its `file` field, along with a `note` text field. This transformation parses the CSV with [papaparse](https://www.papaparse.com/) and replaces the multipart body with JSON.

### 1. Write and bundle the transformation

Transformations can only import Node.js built-in modules, so you [bundle papaparse](/docs/transformations#bundle-dependencies) into your code with esbuild:

```bash
npm install papaparse
npm install --save-dev esbuild

```

Save this as `csv-to-json.js`:

```js
import Papa from 'papaparse';

export default {
  transform(request) {
    const { file, ...fields } = request.body;
    const { data, errors } = Papa.parse(file.content.toString('utf8'), {
      header: true,
      dynamicTyping: true,
      skipEmptyLines: true,
    });
    if (errors.length) throw new Error(`Invalid CSV: ${errors[0].message}`);

    request.body = { ...fields, filename: file.filename, rows: data };
    request.headers['content-type'] = 'application/json';
    return request;
  },
};

```

```bash
npx esbuild csv-to-json.js --bundle --format=esm --platform=node --outfile=dist/csv-to-json.js

```

Paste the contents of `dist/csv-to-json.js` into a new transformation on your connection. See [create a transformation](/docs/transformations#create-a-transformation).

### 2. Upload a file

```bash
curl -X POST "https://hkdk.events/src_xxxxxxxx" \
  -F "note=October export" \
  -F "file=@customers.csv;type=text/csv"

```

### What your destination receives

For a CSV file with two rows, your destination receives `Content-Type: application/json` and this body:

```json
{
  "note": "October export",
  "filename": "customers.csv",
  "rows": [
    { "email": "jane@example.com", "name": "Jane Doe", "plan": "pro", "seats": 5 },
    { "email": "bob@example.com", "name": "Bob Lee", "plan": "free", "seats": 1 }
  ]
}

```

`dynamicTyping` turns numeric values such as `seats` into numbers. If the file isn't valid CSV, the transformation throws and Hookdeck opens a [transformation issue](/docs/transformations#transformation-issues).

## Replace, add, and remove files

To keep the request as multipart and change its files, edit `request.body` and leave the `content-type` header unchanged. This transformation receives a gzip-compressed report and a debug log. It decompresses the report, adds a summary file, and removes the log:

```js
const { gunzipSync } = require('node:zlib');

addHandler('transform', (request) => {
  const { report } = request.body;

  // Replace a file: decompress report.csv.gz into report.csv
  report.content = gunzipSync(report.content);
  report.filename = report.filename.replace(/\.gz$/, '');
  report.content_type = 'text/csv';

  // Add a file
  const rows = report.content.toString('utf8').trim().split('\n').length - 1;
  request.body.summary = {
    filename: 'summary.json',
    content_type: 'application/json',
    content: JSON.stringify({ rows }),
  };

  // Remove a file
  delete request.body.debug_log;

  return request;
});

```

Upload the files:

```bash
curl -X POST "https://hkdk.events/src_xxxxxxxx" \
  -F "note=nightly" \
  -F "report=@report.csv.gz;type=application/gzip" \
  -F "debug_log=@debug.log;type=text/plain"

```

Your destination receives a multipart request with the original boundary and three parts: the `note` field, `report` as `report.csv` with `Content-Type: text/csv` and the decompressed CSV, and `summary` as `summary.json` with `{"rows":2}`.

These rules apply when you edit files:

* Unchanged files keep their original bytes.
* Changing a file's `content`, `filename` or `content_type` replaces the file. `content` can be a `Buffer`, a `Uint8Array` or a string, and Hookdeck computes `size` from the new bytes.
* Adding a field with an object that has `content` adds a file. Set `filename` and `content_type` with it.
* Deleting a field removes it, or remove an entry from a repeated field's array to remove one file.

## Create a multipart upload from JSON

Some APIs only accept file uploads. To send JSON data as a file, set the `content-type` header to `multipart/form-data` and add an object with `content`:

```js
addHandler('transform', (request) => {
  const { orders } = request.body;
  const csv = ['id,total', ...orders.map((order) => `${order.id},${order.total}`)].join('\n');

  request.headers['content-type'] = 'multipart/form-data';
  request.body = {
    description: 'Daily orders',
    file: { filename: 'orders.csv', content_type: 'text/csv', content: csv },
  };
  return request;
});

```

For a JSON request with two orders, your destination receives a multipart request with a `description` text field and a `file` part named `orders.csv`, with `Content-Type: text/csv`:

```
id,total
1,99.5
2,12

```

Hookdeck generates the multipart boundary.

## Put file bytes in JSON

Changed or new file bytes can only be returned in a multipart body. To include a file in a JSON body, encode it, for example as base64:

```js
addHandler('transform', (request) => {
  const { file, ...fields } = request.body;

  request.body = {
    ...fields,
    file: {
      filename: file.filename,
      content_type: file.content_type,
      data: file.content.toString('base64'),
    },
  };
  request.headers['content-type'] = 'application/json';
  return request;
});

```

Returning a `Buffer` inside a JSON body fails the transformation with `File content can only be returned in a multipart/form-data body. To include it in another format, encode it, for example with content.toString('base64').`

When you convert a multipart request to JSON and keep a file object unchanged, the JSON contains the file's `filename`, `content_type` and `size`, without its content.

## Limits and pitfalls

* Execution time. Transformations must finish within 1 second, including the time spent parsing files.
* Memory. Each execution has 128 MB of memory, and parsing a file into objects uses much more memory than the file's size.
* Base64. Base64-encoded files are about a third larger than the original bytes.
* Text encoding. `file.content.toString('utf8')` assumes the file is UTF-8. For other encodings, such as Windows-1252, bundle [iconv-lite](/docs/transformations#npm-libraries) to decode it.
* Older connections. On connections using legacy multipart handling, files are described by their metadata only and have no `content`.

For the modules and libraries transformations can use, see [Transformation Node.js compatibility](/docs/transformations#nodejs-compatibility).