# Files: upload and download

Attach a file to a work item or a Drive item in three steps, and download attached files through a signed link.

`/api/v2` accepts JSON and nothing else, so a file reaches plnnrly in three steps. The middle step goes to a different URL.

1. `POST /api/v2/{parent}/uploads` returns a ticket with an `upload_url`.
2. `PUT` the raw bytes to that `upload_url`, outside `/api/v2`.
3. `POST /api/v2/{parent}/{files}` turns the ticket into a file.

The ticket keeps this safe: the server decides where the bytes go, and no field in any request can change that.

> **Warning:**
> Uploads need a personal access token, and the same token for all three steps. An OAuth access token can create a ticket but gets `401` on step 2. AI clients over MCP cannot upload at all.

In the examples, `<your-url>` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain.

## Where it works

| Parent     | Step 1                                                                                                               | Step 3                                                                                                                    |
| ---------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Work item  | [POST /work-items/{work_item}/uploads](https://developers.plnnrly.com/en/docs/api/v2/work-items/uploads/create/)    | [POST /work-items/{work_item}/attachments](https://developers.plnnrly.com/en/docs/api/v2/work-items/attachments/create/) |
| Drive item | [POST /drive-items/{drive_item}/uploads](https://developers.plnnrly.com/en/docs/api/v2/drive-items/uploads/create/) | [POST /drive-items/{drive_item}/files](https://developers.plnnrly.com/en/docs/api/v2/drive-items/files/create/)          |

## Step 1: create a ticket

Declare the file's name and size:

```bash
curl -X POST "https://<your-url>/api/v2/work-items/812/uploads" \
  -H "Authorization: Bearer <token>" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "file_name": "quarterly-report.pdf",
    "byte_size": 184320
  }'
```

| Field             | Required | Notes                                                                                                                                                            |
| ----------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `file_name`       | yes      | At most 255 characters. Control characters are removed, and `#`, `/`, `\` and spaces become hyphens, so the name you read back can differ from the one you sent. |
| `byte_size`       | yes      | 1 to 10,485,760 (10 MB). A declaration only: the server counts the bytes that actually arrive.                                                                   |
| `checksum_sha256` | no       | 64 lowercase hex characters. When you send it, step 2 refuses bytes with a different SHA-256.                                                                    |

A file name is refused with `422` when any part after its first dot is a server-script extension, such as `php`, `phtml`, `phar`, `cgi`, `pl`, `asp` or `jsp`. So `report.php.pdf` is refused. A name that is empty once cleaned is refused too. Both happen here, before you send any bytes.

The `201` is the ticket. Its `Location` header is the ticket's absolute URL, the same as `links.self`:

```json
{
  "data": {
    "id": 3141,
    "work_item_id": 812,
    "drive_item_id": null,
    "collection": "attachments",
    "file_name": "quarterly-report.pdf",
    "byte_size": 184320,
    "checksum_sha256": null,
    "received_byte_size": null,
    "received_checksum_sha256": null,
    "mime_type": null,
    "status": "pending",
    "upload_url": "<an opaque absolute URL>",
    "expires_at": "2026-10-15T09:42:00Z",
    "register_by": null,
    "uploaded_at": null,
    "committed_at": null,
    "media_id": null,
    "created_at": "2026-10-15T09:12:00Z",
    "updated_at": "2026-10-15T09:12:00Z",
    "links": {
      "self": "https://acme.plnnrly.com/api/v2/uploads/3141"
    }
  }
}
```

- The ticket is yours alone. Somebody else's ticket is `404`, and `422` when you try to register it.
- `expires_at` is 30 minutes after the ticket was created. It is the deadline for step 2.
- You can hold 10 open tickets at a time, across all your tokens. Open means pending, uploaded or being registered. The eleventh is `409` [state-conflict](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#state-conflict) with `too_many_pending_uploads`: register or delete a ticket first. A ticket whose deadline passed keeps counting until the hourly sweep marks it `expired`. Delete it to free the slot at once.
- A deleted work item or an archived Drive item is `404`.

## Step 2: PUT the bytes

This is the one request that does not go to `/api/v2`. Send the raw file as the body, to `upload_url` exactly as you received it. The URL is opaque: do not parse it, change it or build it yourself.

```bash
curl -X PUT "<upload_url>" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @quarterly-report.pdf
```

- Send the file itself, not a form. The body is stored exactly as it arrives, so a multipart envelope would become part of the file.
- `Content-Type` is not read. `application/octet-stream` stops curl from labelling the body as a form.
- A ticket takes its bytes once. A second `PUT` is `409`.

| Status                                                                                                    | When                                                                                                                                            |
| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `204`                                                                                                     | The bytes arrived. The ticket is now `uploaded`.                                                                                                |
| `400`                                                                                                     | The body length does not match `Content-Length`.                                                                                                |
| `401`                                                                                                     | No token, or a token that is not a personal access token.                                                                                       |
| `403`                                                                                                     | The `upload_url` has expired or was changed, the token is not the one that created the ticket, or you may no longer attach files to the parent. |
| `404`                                                                                                     | Not your ticket.                                                                                                                                |
| `408` [request-timeout](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#request-timeout)     | 30 seconds without data, or the upload took longer than 120 seconds.                                                                            |
| `409`                                                                                                     | The ticket already took its bytes or was deleted, or the parent no longer exists.                                                               |
| `413` [payload-too-large](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#payload-too-large) | More than 10 MB, or an empty body with a `Content-Length` above 0.                                                                              |
| `422`                                                                                                     | The bytes do not match `checksum_sha256`, or the body is empty.                                                                                 |
| `429`                                                                                                     | More than 3 uploads at once on one token (with `Retry-After: 5`), or more than 20 `PUT` requests per minute on one token, failed ones included. |
| `500`                                                                                                     | Storage failed. The ticket stays open: retry the `PUT`.                                                                                         |

Errors from this URL carry `type`, `title`, `status` and `detail`, but no `request_id`. A `401`, an expired or changed `upload_url` and the per-minute limit answer with a plain JSON `message` instead. Switch on the status code.

## Step 3: register the ticket

```bash
curl -X POST "https://<your-url>/api/v2/work-items/812/attachments" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9d4b7e20-1c3f-4a86-b5e9-0f2a6c8d1e47" \
  -d '{
    "upload_id": 3141
  }'
```

- `upload_id` is the only field. Any other, such as `file_name` or `mime_type`, is `422`: the file's name, size and type come from the ticket and the bytes.
- `Idempotency-Key` is required here. Without one the answer is `400`. See [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/).
- Before it creates anything, the server hashes the stored bytes again and compares them with what arrived.
- The `201` is the file in the `detail` view. There is no `Location` header: a file has no URL of its own in the API.
- The file's `mime_type` follows the extension of its name. Only when the name has no extension, or one the server does not know, does it fall back to the type detected from the bytes. The ticket's `mime_type` is always the detected type, so the two can differ.
- A file cannot be changed or deleted through the API. Remove it in plnnrly itself.

When registering fails:

| Status                                                                                                                      | Reason                                           | What to do                                                                                        |
| --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `409`                                                                                                                       | `upload_not_received`                            | Do step 2 first.                                                                                  |
| `409`                                                                                                                       | `upload_expired`                                 | The bytes arrived more than 30 minutes ago, or the ticket expired. Start again with a new ticket. |
| `409`                                                                                                                       | `upload_revoked`                                 | The ticket was deleted. Start again.                                                              |
| `409`                                                                                                                       | `upload_already_registered`                      | The ticket is already a file. `conflict.media_id` names it.                                       |
| `409`                                                                                                                       | `upload_registration_in_progress`                | Another registration of this ticket is running. Retry after `Retry-After`.                        |
| `409`                                                                                                                       | `upload_object_missing`, `upload_object_changed` | The staged bytes are gone or no longer match. Start again.                                        |
| `409`                                                                                                                       | `upload_token_mismatch`                          | Use the token that created the ticket.                                                            |
| `422` [validation-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#validation-failed)                   | `/upload_id`, code `exists`                      | Not your ticket, or it was already pruned. Start again with a new ticket.                         |
| `422` [unprocessable-relationship](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unprocessable-relationship) | `/upload_id`, code `exists`                      | The ticket was created for a different parent.                                                    |

Expired tickets are marked by a sweep that runs every hour. Until it runs, a ticket whose step 2 deadline has passed still reads `pending`, and registering it answers `upload_not_received`.

After `upload_object_missing` or `upload_object_changed`, the ticket can stay `committing`. A retry then answers `upload_registration_in_progress` however long you wait, and the ticket cannot be deleted. It counts towards your 10 open tickets until the hourly sweep marks it `expired`, an hour or more after its `expires_at`.

## Check on a ticket

[GET /uploads/{upload}](https://developers.plnnrly.com/en/docs/api/v2/uploads/get/) reads one of your tickets: its status, the size and SHA-256 of the bytes that arrived, the type detected from them, both deadlines and, once registered, the `media_id` of the file.

| `status`     | Means                                                                                |
| ------------ | ------------------------------------------------------------------------------------ |
| `pending`    | Waiting for the bytes. `upload_url` is present in this status only.                  |
| `uploaded`   | The bytes arrived. Register them before `register_by`, 30 minutes after they landed. |
| `committing` | A registration is running.                                                           |
| `committed`  | Done. `media_id` names the file.                                                     |
| `expired`    | A deadline passed. Start again with a new ticket.                                    |
| `revoked`    | The ticket was deleted before it was registered.                                     |

A registered ticket is kept for 24 hours, an expired or revoked one for 7 days. After that it is `404`, so read `media_id` soon after you register.

[DELETE /uploads/{upload}](https://developers.plnnrly.com/en/docs/api/v2/uploads/delete/) revokes a ticket you no longer need: the staged bytes are deleted, the ticket becomes `revoked` and the answer is `204`. It needs a token with the **Delete** scope. A ticket that is registered, or being registered, is `409`.

## Download a file

1. List the files with `include=url`, on [GET /work-items/{work_item}/attachments](https://developers.plnnrly.com/en/docs/api/v2/work-items/attachments/list/) or [GET /drive-items/{drive_item}/files](https://developers.plnnrly.com/en/docs/api/v2/drive-items/files/list/).
2. GET each file's `url` with the same bearer token.

```bash
curl -G "https://<your-url>/api/v2/work-items/812/attachments" \
  -H "Authorization: Bearer <token>" \
  --data-urlencode "include=url"
```

```bash
curl -L "<url>" \
  -H "Authorization: Bearer <token>" \
  -o quarterly-report.pdf
```

- The `url` works only together with your token.
- The `url` does not expire. `url_expires_at` is reserved for later and is always `null`.
- The answer can be a redirect to a short-lived storage link. Follow it: that link carries its own signature and needs no token. `curl -L` follows it and does not send your token on to another host.
- A `null` `url` with a `signed_url_unavailable` warning means files in this organisation cannot be downloaded through the API. They are still available in plnnrly itself.

## Related

- [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/): why step 3 requires a key.
- [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/): the problem types above.
- [What the API cannot do](https://developers.plnnrly.com/en/docs/api/v2/guides/known-limitations/): uploads over OAuth and MCP, and deleting files.
- [Limits at a glance](https://developers.plnnrly.com/en/docs/api/v2/guides/limits/): the upload limits next to all the others.
