Deze referentie is alleen in het Engels. Naar het Nederlandse REST API-overzicht

Diese Referenz gibt es nur auf Englisch. Zur deutschen REST-API-Übersicht

Cette référence existe uniquement en anglais. Aller à la présentation de l'API REST en français

Esta referencia solo está en inglés. Ir a la introducción de la API REST en español

Files: upload and download

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.

View as Markdown

/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

Step 1: create a ticket

Declare the file's name and size:

Terminal
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
  }'
FieldRequiredNotes
file_nameyesAt 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_sizeyes1 to 10,485,760 (10 MB). A declaration only: the server counts the bytes that actually arrive.
checksum_sha256no64 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 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.

Terminal
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.
StatusWhen
204The bytes arrived. The ticket is now uploaded.
400The body length does not match Content-Length.
401No token, or a token that is not a personal access token.
403The 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.
404Not your ticket.
408 request-timeout30 seconds without data, or the upload took longer than 120 seconds.
409The ticket already took its bytes or was deleted, or the parent no longer exists.
413 payload-too-largeMore than 10 MB, or an empty body with a Content-Length above 0.
422The bytes do not match checksum_sha256, or the body is empty.
429More 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.
500Storage 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

Terminal
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.
  • 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:

StatusReasonWhat to do
409upload_not_receivedDo step 2 first.
409upload_expiredThe bytes arrived more than 30 minutes ago, or the ticket expired. Start again with a new ticket.
409upload_revokedThe ticket was deleted. Start again.
409upload_already_registeredThe ticket is already a file. conflict.media_id names it.
409upload_registration_in_progressAnother registration of this ticket is running. Retry after Retry-After.
409upload_object_missing, upload_object_changedThe staged bytes are gone or no longer match. Start again.
409upload_token_mismatchUse the token that created the ticket.
422 validation-failed/upload_id, code existsNot your ticket, or it was already pruned. Start again with a new ticket.
422 unprocessable-relationship/upload_id, code existsThe 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.

statusMeans
pendingWaiting for the bytes. upload_url is present in this status only.
uploadedThe bytes arrived. Register them before register_by, 30 minutes after they landed.
committingA registration is running.
committedDone. media_id names the file.
expiredA deadline passed. Start again with a new ticket.
revokedThe 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

  1. List the files with include=url, on GET/work-items/{work_item}/attachments or GET/drive-items/{drive_item}/files.
  2. GET each file's url with the same bearer token.
Terminal
curl -G "https://<your-url>/api/v2/work-items/812/attachments" \
  -H "Authorization: Bearer <token>" \
  --data-urlencode "include=url"
Terminal
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.