/api/v2 accepts JSON and nothing else, so a file reaches plnnrly in three steps. The middle step goes to a different URL.
POST /api/v2/{parent}/uploadsreturns a ticket with anupload_url.PUTthe raw bytes to thatupload_url, outside/api/v2.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.
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 | POST /work-items/{work_item}/attachments |
| Drive item | POST /drive-items/{drive_item}/uploads | POST /drive-items/{drive_item}/files |
Step 1: create a ticket
Declare the file's name and size:
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:
{
"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, and422when you try to register it. expires_atis 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
409state-conflict withtoo_many_pending_uploads: register or delete a ticket first. A ticket whose deadline passed keeps counting until the hourly sweep marks itexpired. 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.
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-Typeis not read.application/octet-streamstops curl from labelling the body as a form.- A ticket takes its bytes once. A second
PUTis409.
| 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 | 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 | 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: ), 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
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_idis the only field. Any other, such asfile_nameormime_type, is422: the file's name, size and type come from the ticket and the bytes.Idempotency-Keyis required here. Without one the answer is400. See Idempotency.- Before it creates anything, the server hashes the stored bytes again and compares them with what arrived.
- The
201is the file in thedetailview. There is noLocationheader: a file has no URL of its own in the API. - The file's
mime_typefollows 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'smime_typeis 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 | /upload_id, code exists | Not your ticket, or it was already pruned. Start again with a new ticket. |
422 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} 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} 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
- List the files with
include=url, on GET/work-items/{work_item}/attachments or GET/drive-items/{drive_item}/files. - GET each file's
urlwith the same bearer token.
curl -G "https://<your-url>/api/v2/work-items/812/attachments" \
-H "Authorization: Bearer <token>" \
--data-urlencode "include=url"curl -L "<url>" \
-H "Authorization: Bearer <token>" \
-o quarterly-report.pdf- The
urlworks only together with your token. - The
urldoes not expire.url_expires_atis reserved for later and is alwaysnull. - 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 -Lfollows it and does not send your token on to another host. - A
nullurlwith asigned_url_unavailablewarning means files in this organisation cannot be downloaded through the API. They are still available in plnnrly itself.
Related
- Idempotency: why step 3 requires a key.
- Errors and retries: the problem types above.
- What the API cannot do: uploads over OAuth and MCP, and deleting files.
- Limits at a glance: the upload limits next to all the others.