> ## Documentation Index
> Fetch the complete documentation index at: https://productlane.mintlify.site/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# File uploads

> Upload images and documents to use in changelogs, help center articles, and thread attachments.

The `POST /api/v2/files` endpoint accepts a `multipart/form-data` upload and returns a CDN URL you can reference from any other v2 resource that accepts file URLs (changelog `image_url`, help center article body, thread message attachments, etc.).

This is the only v2 endpoint that does not accept JSON - multipart is required because we stream the upload straight to storage without buffering the whole file in memory.

## Authentication

Standard v2 bearer auth. The key must have the `files:write` scope.

```
Authorization: Bearer pl_v2_xxxxxxxxxxxxxxxxxxxxxxxxxx
```

## Limits

| Limit               | Value                   |
| ------------------- | ----------------------- |
| Max file size       | **10 MB** per request   |
| Per-key burst       | **30 uploads / minute** |
| Per-workspace daily | **500 uploads / day**   |

Standard v2 write rate limit (`60/min/key`) also applies, in addition to the upload-specific budget above.

## Allowed content types

Uploads are validated by `Content-Type`. Anything outside this list is rejected with `400 validation_failed`. `image/svg+xml`, `text/html`, and `application/octet-stream` are deliberately excluded to prevent hosting hostile content.

**Images**

* `image/png`
* `image/jpeg`
* `image/gif`
* `image/webp`

**Documents**

* `application/pdf`
* `application/msword` (`.doc`)
* `application/vnd.openxmlformats-officedocument.wordprocessingml.document` (`.docx`)
* `application/vnd.ms-excel` (`.xls`)
* `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (`.xlsx`)
* `application/vnd.ms-powerpoint` (`.ppt`)
* `application/vnd.openxmlformats-officedocument.presentationml.presentation` (`.pptx`)
* `text/csv`
* `text/plain`

## Request

`POST https://productlane.com/api/v2/files`

Body: `multipart/form-data` with a single field named `file`.

| Field  | Type   | Required | Description                                                                     |
| ------ | ------ | -------- | ------------------------------------------------------------------------------- |
| `file` | binary | yes      | The file to upload. Must match one of the allowed content types and be ≤ 10 MB. |

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://productlane.com/api/v2/files \
    -H "Authorization: Bearer pl_v2_xxxxxxxxxxxxxxxxxxxxxxxxxx" \
    -F "file=@./logo.png"
  ```

  ```ts Node.js (fetch) theme={null}
  const form = new FormData();
  form.append("file", new Blob([fileBuffer], { type: "image/png" }), "logo.png");

  const res = await fetch("https://productlane.com/api/v2/files", {
    method: "POST",
    headers: { Authorization: `Bearer ${PL_KEY}` },
    body: form,
  });
  const { file_url } = await res.json();
  ```

  ```python Python (requests) theme={null}
  import requests

  with open("logo.png", "rb") as f:
      res = requests.post(
          "https://productlane.com/api/v2/files",
          headers={"Authorization": f"Bearer {PL_KEY}"},
          files={"file": ("logo.png", f, "image/png")},
      )
  res.raise_for_status()
  file_url = res.json()["file_url"]
  ```
</CodeGroup>

## Response

`200 OK`

```json theme={null}
{
  "file_url": "https://assets.productlane.com/workspaces/ws_.../api/key_.../9f3a....png",
  "content_type": "image/png",
  "size": 24512,
  "original_file_name": "logo.png"
}
```

| Field                | Description                                                                             |
| -------------------- | --------------------------------------------------------------------------------------- |
| `file_url`           | Public CDN URL of the uploaded file. Pass this to any endpoint that accepts a file URL. |
| `content_type`       | Normalized lowercase content type the server stored.                                    |
| `size`               | File size in bytes after upload.                                                        |
| `original_file_name` | Filename you uploaded with, if any. `null` if the multipart part had no filename.       |

The returned URL is scoped to your workspace and tagged with the API key id that uploaded it, so a compromised key can be cleaned up in one sweep.

## Using the URL

The most common destinations:

* **Changelog cover image** - pass `file_url` as `image_url` on `POST /changelogs` or `PATCH /changelogs/{id}`.
* **Help center article body** - embed the URL inline in the article HTML you send to `POST /docs/articles` (`<img src="...">`).
* **Thread message attachments** - pass the URL as part of the `attachments` array on `POST /threads/{thread_id}/messages`.

## Errors

The standard v2 error envelope is returned for every non-2xx response.

| Status | Code                      | When                                                                 |
| ------ | ------------------------- | -------------------------------------------------------------------- |
| 400    | `validation_failed`       | Missing `file` field, content type not allowed, or other validation. |
| 401    | `unauthenticated`         | Missing or invalid bearer token.                                     |
| 403    | `scope_required`          | Key does not have the `files:write` scope.                           |
| 410    | `unsupported_key_version` | v1 key (`pl_*`) hit this endpoint. Mint a v2 key.                    |
| 413    | `validation_failed`       | File exceeds the 10 MB limit.                                        |
| 429    | `rate_limited`            | Per-key or per-workspace upload budget exhausted.                    |

Example error:

```json theme={null}
{
  "error": {
    "code": "validation_failed",
    "message": "Content type \"image/svg+xml\" is not allowed for uploads.",
    "details": {
      "allowed": [
        "application/pdf",
        "image/gif",
        "image/jpeg",
        "image/png",
        "image/webp",
        "text/csv",
        "text/plain"
      ]
    },
    "request_id": "req_01HXY..."
  }
}
```

Every response carries an `X-Request-Id` header. Include it when contacting support.

## Notes

* **No deletion endpoint.** Uploaded files persist; we don't currently expose a way to delete them. If you need a file gone (e.g. credential leak in an upload), revoke the key and contact support.
* **CDN is public.** Don't upload anything that should be private - the URL is unguessable but not auth-gated.
* **Retries are safe but produce a new URL.** This endpoint has no idempotency key; retrying an upload uploads the same content twice and returns two different URLs.
