# Uploads

A `file` field is answered with upload tokens. Upload each file the visitor picks first, then send the tokens in the submission's `files` object (see [Submissions](./submissions)). Like submissions, uploads need a `forms:write` key, so send them from your server, never from the browser (see [Keep forms:write keys on your server](./index#keep-formswrite-keys-on-your-server)).

## POST /v1/api/forms/{key}/uploads

Stores one file for one `file` field of the form's published version and returns a token for it. Requires the `forms:write` scope. The form must be active and published.

The file is stored privately: it has no public URL, and the merchant opens it from the dashboard inbox once a submission claims it.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | string | Yes | The form key, for example `quote-request`. |

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Authorization` | string | Yes | `Bearer {forms_write_key}`. |
| `Content-Type` | string | Yes | `multipart/form-data` (set by your HTTP client when you send form data). |
| `X-Borkol-Visitor-Ip` | string | No | The visitor's IP address (IPv4 or IPv6). The upload limit counts per visitor address. Missing or not a valid IP address: the address of the connection (your server) is used instead. See [Visitor IP and the hourly limit](./index#visitor-ip-and-the-hourly-limit). |

### Body parameters

Send the body as `multipart/form-data`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `field` | string | Yes | The key of a `file` field in the form's published version, at most 64 characters. |
| `file` | file | Yes | One file. Its type is judged by its content, not by its name or the `Content-Type` the browser sent, and must match the field's `accept`: `image` lets through JPEG, PNG, WebP and HEIC/HEIF; `pdf` lets through PDF. SVG and every other type are refused. At most the field's `max_mb` (1 to 20 MB). |

```bash title="Request"
curl -X POST "https://api.borkol.com/v1/api/forms/quote-request/uploads" \
  -H "Authorization: Bearer {forms_write_key}" \
  -H "Accept: application/json" \
  -H "X-Borkol-Visitor-Ip: 203.0.113.24" \
  -F "field=brief" \
  -F "file=@brief.pdf"
```

```javascript title="Request (JS)"
// A Next.js route handler: the browser posts multipart (field, file) here, never to Borkol.
export async function POST(request) {
  // Only trust an IP header your own proxy or host sets.
  const visitorIp = request.headers.get("x-real-ip") ?? "";
  const res = await fetch("https://api.borkol.com/v1/api/forms/quote-request/uploads", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.BORKOL_FORMS_KEY}`,
      Accept: "application/json",
      "X-Borkol-Visitor-Ip": visitorIp,
    },
    body: await request.formData(),
  });
  return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
}
```

Status: `201 Created`. The result is wrapped in `data`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `token` | string (UUID) | always | Send it in the submission's `files` under the same field key. |
| `name` | string | always | The stored file name: the visitor's file name without any path or control characters, at most 150 characters before the extension, with the extension its content proves (`jpg`, `png`, `webp`, `heic` or `pdf`). `file` when nothing is left of the name. |
| `size` | integer | always | The file size in bytes. |

```json title="Response"
{
  "data": {
    "token": "9b2f0c3e-5a1d-4f7e-8c2b-1d3e4f5a6b7c",
    "name": "brief.pdf",
    "size": 482113
  }
}
```

### Tokens

- A token belongs to the form and the field it was uploaded for. Sent for another form or another field, the submission answers `422` on `files.{key}`.
- A token works for **one** submission. A submission that is rejected (any `422`) claims nothing, so the same tokens can be sent again with the corrected submission.
- A token expires **24 hours** after the upload. An expired token answers `422` on `files.{key}`; upload the file again. Files that no submission claimed are deleted once their token has expired.
- Upload one file per request. For a field with `max_files` above 1, upload each file and send all tokens in one list, at most `max_files`.
- The upload does not look at the form's rules: whether the field is shown is decided when the submission arrives. Tokens for a field that is hidden at that moment are ignored and expire unused.

### Upload limit

Each form accepts **30 uploads per visitor per hour**, counted per visitor address from `X-Borkol-Visitor-Ip`, separately from the submission limit. Every attempt counts, including uploads that fail with `422`. Over the limit:

```json title="Response"
{
  "message": "Too many uploads. Try again later."
}
```

The response has status `429` and carries no `Retry-After` header. The store-wide limit of 120 requests per minute applies as well (see [Errors and rate limits](/api/getting-started/errors-and-rate-limits)).

### Errors

**422 validation.** The standard envelope (see [Errors and rate limits](/api/getting-started/errors-and-rate-limits)):

| Key | Message | When |
| --- | --- | --- |
| `field` | Laravel's standard messages | Missing, not a string or longer than 64 characters. |
| `field` | `Not a file field of this form.` | No `file` field with that key in the published version. |
| `file` | Laravel's standard messages | Missing, not an uploaded file, or larger than 20 MB. |
| `file` | `The file may not be larger than {max} MB.` | Larger than the field's `max_mb`. |
| `file` | `This file type is not accepted here.` | The content is not a type the field's `accept` allows. |

```json title="Response"
{
  "message": "This file type is not accepted here.",
  "errors": {
    "file": ["This file type is not accepted here."]
  }
}
```

**Other errors.**

- `404` when no form of the store has that key, or it is a draft, archived, never published or deleted.
- `403` `{"error":"Module not enabled: forms"}` when the forms module is off for the store, and `403` `{"message":"Invalid ability provided."}` when the key lacks `forms:write`.
- `401` as on every `/v1/api` route.
